97 lines
9.9 KiB
TypeScript
97 lines
9.9 KiB
TypeScript
/** DOM contracts only; no browser layout/pixel/REA claim. See task-popconfirm-regression for temporary jsdom setup. */
|
|
import assert from 'node:assert/strict'
|
|
import { createRequire } from 'node:module'
|
|
const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
|
|
const dom = new JSDOM('<html><body><main id="root"></main></body></html>', {pretendToBeVisual:true,url:'http://localhost/'})
|
|
for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','MouseEvent','KeyboardEvent','Event']) 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})
|
|
const React=require('react') as typeof import('react')
|
|
const {act}=React
|
|
const {createRoot}=require('react-dom/client') as typeof import('react-dom/client')
|
|
const {QuestionFloating}=require('../src/renderer/src/workbuddy/conversation/QuestionFloating') as typeof import('../src/renderer/src/workbuddy/conversation/QuestionFloating')
|
|
const {projectQuestionAnswers}=require('../src/renderer/src/workbuddy/conversation/question-projection') as typeof import('../src/renderer/src/workbuddy/conversation/question-projection')
|
|
const {renderToStaticMarkup}=require('react-dom/server') as typeof import('react-dom/server')
|
|
const MessageBubble=require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default
|
|
const {CompletedFold,LiveEventDisclosure}=require('../src/renderer/src/workbuddy/conversation/CompletedFold') as typeof import('../src/renderer/src/workbuddy/conversation/CompletedFold')
|
|
const root=createRoot(document.getElementById('root')!)
|
|
const click=(el:Element)=>el.dispatchEvent(new MouseEvent('click',{bubbles:true}))
|
|
const key=(el:Element,k:string,extra={})=>el.dispatchEvent(new KeyboardEvent('keydown',{key:k,bubbles:true,cancelable:true,...extra}))
|
|
const input=(value:string)=>{const el=document.querySelector('input')!; Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype,'value')!.set!.call(el,value);el.dispatchEvent(new Event('input',{bubbles:true}))}
|
|
let checks=0
|
|
const check=(condition:unknown,label:string)=>{assert.ok(condition,label); checks++}
|
|
async function main(){
|
|
let calls=0;let fail=true;let answer:any
|
|
const config={toolCallId:'q',questions:[{id:'one',question:'First',options:['A','B']},{id:'two',question:'Second',options:['X','Y'],multiSelect:true}]}
|
|
await act(async()=>root.render(<QuestionFloating config={config} onSelect={(_text,payload)=>{calls++; answer=payload;if(fail)throw Error('sync fail')}}/>))
|
|
check(document.activeElement?.getAttribute('role')==='button','first option receives focus')
|
|
await act(async()=>{key(document.activeElement!,'Enter');await new Promise(r=>setTimeout(r,140))})
|
|
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='Second','keyboard single choice advances page')
|
|
await act(async()=>click(document.querySelectorAll('.question-floating__option')[1]))
|
|
await act(async()=>input('custom answer'))
|
|
await act(async()=>{click(document.querySelector('.question-floating__advance')!);click(document.querySelector('.question-floating__advance')!)})
|
|
check(calls===1,'same-tick duplicate submit locked')
|
|
check(document.querySelector('[role="alert"]')?.textContent==='sync fail','sync throw visible and retryable')
|
|
check(answer.questions[1].answers.join('|')==='Y|custom answer','multi-select custom supplement preserved')
|
|
fail=false
|
|
await act(async()=>click(document.querySelector('.question-floating__advance')!))
|
|
check(calls===2,'retry succeeds')
|
|
let reject:(e:Error)=>void=()=>{};let skipCalls=0
|
|
await act(async()=>root.render(<QuestionFloating key="skip" config={config} onSkip={()=>{skipCalls++;return new Promise<void>((_,r)=>{reject=r})}}/>))
|
|
await act(async()=>key(document.querySelector('[role="dialog"]')!,'Escape'))
|
|
check(skipCalls===1,'Escape submits skip')
|
|
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-busy')==='true','skip pending busy')
|
|
await act(async()=>reject(Error('async fail')))
|
|
check(document.querySelector('[role="alert"]')?.textContent==='async fail','async rejection surfaced')
|
|
check(!document.querySelector<HTMLButtonElement>('.question-floating__dismiss')?.disabled,'skip failure unlocks controls')
|
|
let sends=0
|
|
await act(async()=>root.render(<QuestionFloating key="ime" config={{questions:[{id:'i',question:'Input',options:[]}]}} onSelect={()=>{sends++}}/>))
|
|
await act(async()=>input('中文'))
|
|
await act(async()=>key(document.querySelector('input')!,'Enter',{isComposing:true}))
|
|
check(sends===0,'IME Enter does not submit')
|
|
await act(async()=>key(document.querySelector('input')!,'Enter'))
|
|
check(sends===1,'plain Enter submits custom answer')
|
|
// A queued auto-advance from the old request must not move a replacement request.
|
|
await act(async()=>root.render(<QuestionFloating key="replace" config={config}/>))
|
|
await act(async()=>click(document.querySelector('.question-floating__option')!))
|
|
await act(async()=>root.render(<QuestionFloating key="replace" config={{...config,toolCallId:'new'}}/>))
|
|
await act(async()=>new Promise(r=>setTimeout(r,140)))
|
|
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='First','request replacement cancels old delayed advance')
|
|
// Manual pagination must retire a previously selected option's delayed navigation.
|
|
const three={toolCallId:'three',questions:[{id:'a',question:'Page A',options:['A']},{id:'b',question:'Page B',options:['B']},{id:'c',question:'Page C',options:['C']}]}
|
|
await act(async()=>root.render(<QuestionFloating key="manual-page" config={three}/>))
|
|
await act(async()=>click(document.querySelector('.question-floating__option')!))
|
|
await act(async()=>click(document.querySelectorAll('.question-floating__page-button')[1]))
|
|
await act(async()=>new Promise(r=>setTimeout(r,140)))
|
|
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='Page B','manual next retires delayed advance instead of skipping a page')
|
|
await act(async()=>root.render(<QuestionFloating key="manual-skip" config={three}/>))
|
|
await act(async()=>click(document.querySelector('.question-floating__option')!))
|
|
await act(async()=>click(document.querySelector('.question-floating__skip')!))
|
|
await act(async()=>new Promise(r=>setTimeout(r,140)))
|
|
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='Page B','skip retires delayed advance instead of skipping two pages')
|
|
const legacyAnswer={questions:[{id:'legacy-question',question:'Legacy unique question',answers:['yes']}]}
|
|
const legacy: import('../src/shared/types').ChatMessage={id:'legacy',role:'assistant',createdAt:1,content:'Final prose',status:'done',tools:[{id:'legacy-tool',name:'AskUserQuestion',args:{},argsRaw:'{}',status:'ok',metadata:{questionAnswer:legacyAnswer}}],interactions:[{id:'legacy-interaction',runId:'old',kind:'question',toolCallId:'legacy-tool',title:'Question',questions:[],status:'answered',answer:legacyAnswer}]}
|
|
const projected=projectQuestionAnswers(legacy)
|
|
check(projected.positionedAnswers.size===0 && projected.unpositioned.length===1,'fallback tool order is not treated as recorded question position')
|
|
check(projected.unpositionedToolIds.has('legacy-tool'),'legacy duplicate tool rendering is suppressed')
|
|
const legacyMarkup=renderToStaticMarkup(<MessageBubble message={legacy}/>)
|
|
check(legacyMarkup.includes('历史问答(原始位置不可用)') && (legacyMarkup.match(/Legacy unique question/g)||[]).length===1,'legacy answer displayed exactly once with unknown-position label')
|
|
check(projectQuestionAnswers({...legacy,interactions:[]}).unpositioned.length===1,'metadata-only legacy answer preserves unknown-position compatibility path')
|
|
const parts=[{type:'reasoning',id:'r',text:'thought'},{type:'tool',toolId:'t'},{type:'text',text:'A'},{type:'tool',toolId:'later'}] as const
|
|
await act(async()=>root.render(<CompletedFold parts={parts} turnFinished turnDurationMs={1000} renderUnit={(_p,i)=><i data-order={i}/>}/>))
|
|
check(document.querySelector('.cr-collapse__header')?.getAttribute('aria-expanded')==='false','completion initially collapsed')
|
|
await act(async()=>key(document.querySelector('.cr-collapse__header')!,'Enter'))
|
|
check([...document.querySelectorAll('[data-order]')].map(n=>n.getAttribute('data-order')).join(',')==='0,1,2,3','expanded sequence preserves original order')
|
|
await act(async()=>key(document.querySelector('.cr-collapse__header')!,' '))
|
|
check(document.querySelector('.cr-collapse__header')?.getAttribute('aria-expanded')==='false','keyboard collapses completion independently')
|
|
function Groups(){const [open,setOpen]=React.useState<Record<string,boolean>>({});return <>{['first','second'].map(id=><LiveEventDisclosure key={id} id={id} header={id} expanded={open[id]??false} onExpandedChange={value=>setOpen(previous=>({...previous,[id]:value}))}><span>{id} details</span></LiveEventDisclosure>)}</>}
|
|
await act(async()=>root.render(<Groups/>))
|
|
await act(async()=>click(document.querySelectorAll('.cr-collapse__header')[0]))
|
|
check(document.querySelectorAll('.cr-collapse__header')[0].getAttribute('aria-expanded')==='true' && document.querySelectorAll('.cr-collapse__header')[1].getAttribute('aria-expanded')==='false','first process expansion does not expand second group')
|
|
await act(async()=>click(document.querySelectorAll('.cr-collapse__header')[1]))
|
|
await act(async()=>click(document.querySelectorAll('.cr-collapse__header')[0]))
|
|
check(document.querySelectorAll('.cr-collapse__header')[0].getAttribute('aria-expanded')==='false' && document.querySelectorAll('.cr-collapse__header')[1].getAttribute('aria-expanded')==='true','second group retains independent state')
|
|
await act(async()=>root.unmount());dom.window.close();console.log(`question-interaction: ${checks} DOM behavioral checks passed`);process.exit(0)
|
|
}
|
|
main().catch(error=>{console.error(error);dom.window.close();process.exit(1)})
|