90 lines
8.1 KiB
TypeScript
90 lines
8.1 KiB
TypeScript
/** jsdom behavior only; no layout, browser pixels, SDK process or model calls. */
|
|
import assert from 'node:assert/strict'
|
|
import { createRequire } from 'node:module'
|
|
import { readFileSync } from 'node:fs'
|
|
const externalRequire=createRequire(process.cwd()+'/package.json')
|
|
const {JSDOM}=externalRequire(process.env.YCODE_TEST_DOM_MODULE||'jsdom')
|
|
const dom=new JSDOM('<html><body><main id="root"></main></body></html>',{url:'http://localhost/',pretendToBeVisual:true})
|
|
for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','HTMLTextAreaElement','MouseEvent','KeyboardEvent','Document','Text','DOMParser','MutationObserver','Range','ShadowRoot','File','FileReader','Event','getComputedStyle']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true})
|
|
Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true})
|
|
Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true,requestAnimationFrame:dom.window.requestAnimationFrame.bind(dom.window),cancelAnimationFrame:dom.window.cancelAnimationFrame.bind(dom.window)})
|
|
const composerStyle=document.createElement('style')
|
|
composerStyle.textContent=['composer.css','composer-source.css'].map(file=>readFileSync(`src/renderer/src/workbuddy/composer/${file}`,'utf8')).join('\n')
|
|
document.head.append(composerStyle)
|
|
const React=require('react') as typeof import('react')
|
|
const {act}=React
|
|
const {createRoot}=require('react-dom/client') as typeof import('react-dom/client')
|
|
const {ComposerControls}=require('../src/renderer/src/workbuddy/composer/ComposerControls') as typeof import('../src/renderer/src/workbuddy/composer/ComposerControls')
|
|
const {composeComposer}=require('../src/renderer/src/workbuddy/composer/input-composition') as typeof import('../src/renderer/src/workbuddy/composer/input-composition')
|
|
const {QuestionFloating}=require('../src/renderer/src/workbuddy/conversation/QuestionFloating') as typeof import('../src/renderer/src/workbuddy/conversation/QuestionFloating')
|
|
const {findActiveComposerInteraction}=require('../src/renderer/src/workbuddy/composer/active-interaction') as typeof import('../src/renderer/src/workbuddy/composer/active-interaction')
|
|
const root=createRoot(document.getElementById('root')!)
|
|
let checks=0
|
|
const check=(value:unknown,message:string)=>{assert.ok(value,message);checks++}
|
|
const click=(selector:string)=>document.querySelector(selector)!.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}))
|
|
const question: import('../src/shared/conversation').ChatInteraction = {id:'question',kind:'question',runId:'run-a',toolCallId:'ask-a',status:'pending',title:'Release?',questions:[{id:'q',question:'Release?',options:['Fix first','Release now']}]}
|
|
let session='session-a', running:{runId:string;sessionId:string}|null={runId:'run-a',sessionId:'session-a'}, unavailable=false
|
|
let status=question.status, sends=0,stops=0,answers=0,skips=0,changes=0
|
|
let resolve!:()=>void,reject!:(error:Error)=>void
|
|
const draft='Existing unsent draft'
|
|
const attachments=[{id:'attachment',name:'draft.txt',mimeType:'text/plain',size:1}]
|
|
const messages=()=>[{id:'assistant',role:'assistant' as const,content:'',createdAt:1,status:'streaming' as const,interactions:[{...question,status}]}]
|
|
const render=()=>act(async()=>{
|
|
const active=findActiveComposerInteraction(messages(),session,running,unavailable)
|
|
const composition=composeComposer({interaction:active,queueCount:2,running:!!running})
|
|
const surface=active?.kind==='question'?<QuestionFloating config={{questions:active.questions,toolCallId:active.toolCallId}} responding={status==='responding'}
|
|
onSelect={()=>{answers++;return new Promise<void>((yes,no)=>{resolve=yes;reject=no})}} onSkip={async()=>{skips++;status='cancelled'}}/>:undefined
|
|
root.render(<ComposerControls key={session} sessionId={session} value={draft} onChange={()=>{changes++}} onSend={()=>{sends++}} onStop={()=>{stops++}}
|
|
attachments={attachments} resources={['/fixture/keep.ts']} approvalKey={active?.id}
|
|
approvalSlot={!composition.input.visible?surface:undefined} floatingSlot={composition.input.visible?surface:undefined}
|
|
queueSlot={composition.queue.visible?<div data-queue="true">queued</div>:undefined}/>)
|
|
})
|
|
async function main(){
|
|
status='answered';await render()
|
|
const stack=document.querySelector<HTMLElement>('.conversation-input-area__input-stack')!
|
|
const editor=stack.querySelector<HTMLElement>('[contenteditable]')!
|
|
await act(async()=>editor.focus())
|
|
status='pending';await render()
|
|
check(stack===document.querySelector('.conversation-input-area__input-stack'),'question preserves mounted draft stack')
|
|
check(stack.hidden&&stack.hasAttribute('inert')&&stack.getAttribute('aria-hidden')==='true','pending question hides/inerts/accessibility-hides ordinary composer')
|
|
check(getComputedStyle(stack).display==='none','normal composer removed from layout')
|
|
check(!stack.contains(document.activeElement),'focus leaves hidden editor')
|
|
check(document.querySelectorAll('[role="dialog"]').length===1&&!!document.querySelector('.question-floating__option'),'only question surface shown')
|
|
check(!document.querySelector('.wb-composer__toolbar, [data-queue], button[aria-label="停止"],button[aria-label="发送"]'),'ordinary controls and queue absent; no extra stop')
|
|
const changesBefore=changes
|
|
await act(async()=>{editor.focus();editor.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}))})
|
|
check(!stack.contains(document.activeElement)&&sends===0&&changes===changesBefore,'hidden editor cannot focus, edit or send')
|
|
await act(async()=>click('.question-floating__option'))
|
|
await act(async()=>click('.question-floating__advance'))
|
|
check(answers===1,'question submits through its own callback')
|
|
status='responding';await render()
|
|
check(stack.hidden&&document.querySelector('[role="dialog"]')?.getAttribute('aria-busy')==='true','responding question retains exclusivity')
|
|
await act(async()=>reject(Error('retry fixture')))
|
|
status='error';await render()
|
|
check(stack.hidden&&document.querySelector('[role="alert"]')?.textContent?.includes('retry fixture'),'failed answer remains retryable without exposing composer')
|
|
await act(async()=>click('.question-floating__advance'));await act(async()=>resolve())
|
|
status='answered';await render()
|
|
check(!stack.hidden&&!stack.hasAttribute('inert'),'answered restores composer')
|
|
check(stack.querySelector('[contenteditable]')===editor&&stack.textContent?.includes(draft)&&stack.textContent.includes('draft.txt')&&stack.textContent.includes('keep.ts'),'same editor draft attachments resources survive')
|
|
check(!!document.querySelector('[data-queue]')&&!!document.querySelector('.wb-composer__toolbar'),'queue toolbar restored')
|
|
status='pending';await render();await act(async()=>click('.question-floating__dismiss'));await render()
|
|
check(skips===1&&!stack.hidden,'dismiss/skip restores normal input after terminal state')
|
|
status='pending';running=null;await render()
|
|
check(!stack.hidden&&!document.querySelector('[role="dialog"]'),'historical pending question without live run cannot take over')
|
|
running={runId:'other-run',sessionId:session};await render()
|
|
check(!stack.hidden,'old-run question does not take over current run')
|
|
running={runId:'run-a',sessionId:'other-session'};await render()
|
|
check(!stack.hidden,'other-session question does not take over')
|
|
running={runId:'run-a',sessionId:session};unavailable=true;await render()
|
|
check(!stack.hidden,'hidden/loading/error pane has no actionable question')
|
|
unavailable=false;status='cancelled';await render()
|
|
check(!stack.hidden,'cancelled history question leaves normal composer')
|
|
check(sends===0&&stops===0,'question transitions never invoke normal send or stop')
|
|
// Approval behavior remains unchanged, including selection without a live run.
|
|
const approval={...question,kind:'approval' as const,requestId:'approval',options:[]}
|
|
check(findActiveComposerInteraction([{...messages()[0],interactions:[approval]}],session,null)?.kind==='approval','existing approval selection unchanged')
|
|
await act(async()=>root.unmount());dom.window.close()
|
|
console.log(`Question composer DOM: ${checks} checks passed (not pixel/SDK verification)`);process.exit(0)
|
|
}
|
|
main().catch(error=>{console.error(error);process.exit(1)})
|