/** 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('
',{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'?{answers++;return new Promise((yes,no)=>{resolve=yes;reject=no})}} onSkip={async()=>{skips++;status='cancelled'}}/>:undefined root.render({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?
queued
:undefined}/>) }) async function main(){ status='answered';await render() const stack=document.querySelector('.conversation-input-area__input-stack')! const editor=stack.querySelector('[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)})