import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve, join, dirname } from 'node:path'; import { pathToFileURL } from 'node:url'; const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url); const { chromium } = require('playwright'); const { createServer } = await import(pathToFileURL(resolve('web/node_modules/vite/dist/node/index.js'))); const work = await mkdtemp(join(tmpdir(), 'story-dialog-focus-')); const evidenceDir = process.env.SUBJECT_CANCEL_EVIDENCE_DIR; if (evidenceDir) await mkdir(evidenceDir, {recursive:true}); await mkdir(join(work, 'empty-env')); const server = await createServer({ configFile: false, root: resolve('web'), envDir: join(work, 'empty-env'), cacheDir: join(work, 'vite-cache'), oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 }, plugins: [{ name: 'creation-dialog-focus-fixture', enforce: 'pre', transform(code,id) { if (!id.replaceAll('\\','/').split('?')[0].endsWith('/src/pages/SaaSWorkspace.tsx')) return; // Control only the asynchronous image processor, while retaining the // actual file input, React handler, scope checks, and preview rendering. const marker='async function prepareAvatarDataURL(file: File) {'; assert.ok(code.includes(marker)); return {code:code.replace(marker,marker+'\n if (typeof window.__avatarTestProcess === "function") return window.__avatarTestProcess(file);'),map:null}; }, configureServer(vite) { vite.middlewares.use('/__dialog-test', (_request, response) => { response.setHeader('Content-Type', 'text/html'); response.end('
'); }); }, resolveId(id) { if (id === '__dialog-fixture.tsx') return '\0__dialog-fixture.tsx'; }, load(id) { if (id === '\0__dialog-fixture.tsx') return ` import React from 'react'; import {createRoot} from 'react-dom/client'; import {SaaSWorkspace} from '/src/pages/SaaSWorkspace.tsx'; import '/src/styles.css'; import '/src/cal-theme.css'; import '/src/customer-responsive.css'; const accounts=[{tenant_id:'qa-tenant',user_id:'qa-user',account_kind:'customer',tenant_name:'合成测试',display_name:'QA',policy_version:'local-v1',household_memberships:[]}]; function App(){ const [section,setSection]=React.useState(new URL(location.href).searchParams.get('section')||'subjects'); const [token,setToken]=React.useState('synthetic-original'); window.changeTestToken=()=>setToken('synthetic-next'); return React.createElement(SaaSWorkspace,{accounts,accessToken:token,section,onSectionChange:setSection,redesignedHome:true,showNavigation:false}); } createRoot(document.getElementById('root')).render(React.createElement(App)); `; }, }], }); await server.listen(); const origin = `http://127.0.0.1:${server.httpServer.address().port}`; const browser = await chromium.launch({ headless: true, ...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}) }); const errors = [], blocked = [], checks = []; const capabilities = ['household.read','household.manage','member.manage','ownership.transfer','subject.read','subject.write','privacy.read','privacy.manage','conversation.read','story.review']; const family = id => ({ id, name:`测试家庭${id}`, tenant_id:'qa-tenant', status:'active', role:'owner', capabilities, version:1 }); const elder = id => ({id:`elder-${id}`,household_id:id,preferred_name:`测试长者${id}`,language:'zh-CN',timezone:'Asia/Shanghai',profile_data:{background:'合成背景',interests:[]},conversation_preferences:{pace:'slow',conversation_goal:'日常陪伴',preferred_topics:[],avoided_topics:[]},status:'active',version:1}); async function fixturePage(section = 'subjects', viewport = {width:389,height:844}) { const context = await browser.newContext({viewport,serviceWorkers:'block'}); await context.addInitScript(() => localStorage.setItem('companion:household:qa-tenant/qa-user','A')); const page = await context.newPage(); page.setDefaultTimeout(15000); const fixture = { creates:[], renames:[], families:[family('A'),family('B')], subjects:{A:[elder('A')],B:[elder('B')]}, holdCreate:false, holdRename:false }; page.on('pageerror', error => errors.push(error.message)); await page.route('**/*', async route => { const request=route.request(), url=new URL(request.url()), path=url.pathname, method=request.method(); if (url.origin!==origin) { blocked.push(url.origin+path); return route.abort('blockedbyclient'); } if (!path.startsWith('/api/')) return route.continue(); const body=request.postData()?request.postDataJSON():undefined; const answer=data=>route.fulfill({json:{ok:true,data,request_id:'synthetic-dialog'},headers:{ETag:'"v1"'}}); if(path==='/api/v1/events') return route.fulfill({contentType:'text/event-stream',body:': synthetic fixture\n\n'}); if(path==='/api/v1/households') return answer({items:fixture.families}); const householdSubjects=path.match(/^\/api\/v1\/households\/([^/]+)\/subjects$/); if(householdSubjects && method==='GET') return answer({items:fixture.subjects[householdSubjects[1]]||[]}); if(householdSubjects && method==='POST') { fixture.creates.push({path,body}); if(fixture.holdCreate) await new Promise(resolve=>fixture.releaseCreate=resolve); const created={...elder(householdSubjects[1]),...body,id:'synthetic-created'}; fixture.subjects[householdSubjects[1]].push(created); return answer(created); } const householdPatch=path.match(/^\/api\/v1\/households\/([^/]+)$/); if(householdPatch && method==='PATCH') { fixture.renames.push({path,body,ifMatch:request.headers()['if-match']}); if(fixture.holdRename) await new Promise(resolve=>fixture.releaseRename=resolve); const changed={...family(householdPatch[1]),...body,version:2}; fixture.families=fixture.families.map(item=>item.id===changed.id?changed:item); return answer(changed); } if(path.endsWith('/members')) return answer({items:[{id:'qa-user',user_id:'qa-user',display_name:'合成测试成员',role:'owner',status:'active',is_owner:true,version:1,joined_at:'2026-10-06T00:00:00Z'}]}); if(path.endsWith('/conversation-readiness')||path.endsWith('/readiness')) return answer({subject_id:'elder-A',ready:false,checks:[],devices:[],start_time_checks:[]}); if(method==='GET'||(method==='POST'&&path.endsWith('/query'))) return answer({items:[],next_cursor:''}); errors.push(`Unexpected synthetic mutation: ${method} ${path}`); return route.fulfill({status:400,json:{ok:false,error:{code:'SYNTHETIC_REJECTED',message:'Not a fixture mutation'}}}); }); await page.goto(`${origin}/__dialog-test?section=${section}`); await page.locator('.ch-sidebar .ch-household').waitFor({state:'attached'}); return { page, context, fixture }; } const addButton = page => page.getByRole('button',{name:/添加长者|新增长者/}).first(); const creator = page => page.locator('dialog.subject-create-dialog'); const focusedInside = page => page.evaluate(()=>document.querySelector('dialog[open]')?.contains(document.activeElement)); async function openCreator(page) { try { await addButton(page).click(); await creator(page).waitFor(); } catch(error) { console.error('Synthetic dialog diagnostics:',errors,blocked,(await page.locator('body').innerText()).slice(0,3000)); throw error; } } async function fillCreator(page) { await creator(page).getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶').fill('合成新长者'); await creator(page).getByPlaceholder('例如:退休前是教师,年轻时在天津生活过').fill('仅本地模拟'); } async function switchFamily(page,id) { // Simulate a scope change supplied by surrounding navigation while a modal // is open; native inert correctly prevents an ordinary background click. await page.locator('.household-tabs button').filter({hasText:`测试家庭${id}`}).evaluate(element=>element.click()); } async function wrapCheck(page, dialog) { const first=dialog.getByRole('button',{name:/^关闭/}).first(); const cancel=dialog.getByRole('button',{name:'取消',exact:true}); await cancel.focus(); await page.keyboard.press('Tab'); assert.equal(await first.evaluate(element=>element===document.activeElement),true); await page.keyboard.press('Shift+Tab'); assert.equal(await cancel.evaluate(element=>element===document.activeElement),true); for(let step=0;step<18;step++){await page.keyboard.press('Tab');assert.equal(await focusedInside(page),true);} } async function installAvatarJobs(page) { await page.evaluate(() => { window.avatarJobs=[]; window.__avatarTestProcess=file=>new Promise((resolve,reject)=>window.avatarJobs.push({name:file.name,resolve,reject})); const canvas=document.createElement('canvas'); canvas.width=2; canvas.height=2; const context=canvas.getContext('2d'); window.avatarImages={}; for(const color of ['red','blue','green']) {context.fillStyle=color;context.fillRect(0,0,2,2);window.avatarImages[color]=canvas.toDataURL('image/png');} }); } async function queueAvatar(page,name) { const index=await page.evaluate(()=>window.avatarJobs.length); await creator(page).locator('input[type=file]').setInputFiles({name,mimeType:'image/png',buffer:Buffer.from('synthetic-image-processor-input')}); await page.waitForFunction(count=>window.avatarJobs.length===count+1,index); return index; } async function finishAvatar(page,index,color,error=false) { await page.evaluate(async ({index,color,error})=>{ const job=window.avatarJobs[index]; if(error) job.reject(new Error('Synthetic stale image error')); else job.resolve(window.avatarImages[color]); await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); },{index,color,error}); } const avatarSource = page => creator(page).locator('.subject-avatar-upload img').getAttribute('src'); const draftSnapshot = page => creator(page).evaluate(dialog => ({ fields: Array.from(dialog.querySelectorAll('input:not([type=file]), textarea, select'), element => element.value), avatar: dialog.querySelector('.subject-avatar-upload img')?.getAttribute('src') ?? null, avatarError: dialog.querySelector('.subject-avatar-error')?.textContent ?? null, nextDisabled: dialog.querySelector('button[type=submit]').disabled, })); async function fillFullDraft(page) { await fillCreator(page); await creator(page).getByLabel(/^交流节奏/).selectOption('lively'); await creator(page).getByLabel(/^兴趣标签/).fill('合成兴趣'); await creator(page).getByLabel(/^长期陪伴方向/).fill('合成陪伴方向'); await creator(page).getByLabel(/^优先话题/).fill('合成优先话题'); await creator(page).getByLabel(/^避免主动提及/).fill('合成避免话题'); const job=await queueAvatar(page,'draft-avatar.png'); await finishAvatar(page,job,'blue'); return draftSnapshot(page); } try { for (const viewport of [{width:389,height:844},{width:1188,height:761}]) { const {page,context,fixture}=await fixturePage('subjects',viewport); await installAvatarJobs(page); await openCreator(page); const cleanDraft=await draftSnapshot(page); assert.equal(cleanDraft.nextDisabled,true); assert.equal(cleanDraft.avatar,null); for (const dismissal of ['cancel','cancel','close','escape']) { await fillFullDraft(page); if(dismissal==='escape') await page.keyboard.press('Escape'); else await creator(page).getByRole('button',{name:dismissal==='cancel'?'取消':'关闭新增长者弹窗',exact:true}).click(); await creator(page).waitFor({state:'detached'}); assert.equal(fixture.creates.length,0); await openCreator(page); assert.deepEqual(await draftSnapshot(page),cleanDraft); } if(evidenceDir) await page.screenshot({path:join(evidenceDir,`reopened-clean-${viewport.width}.png`)}); checks.push(`${viewport.width}px: Cancel twice, X and Escape discard all fields/avatar; repeated opens are clean and Next disabled; no creation`); const filledDraft=await fillFullDraft(page), consent=page.locator('dialog.creation-consent-dialog'); for(let round=0;round<2;round++) { await creator(page).getByRole('button',{name:'下一步',exact:true}).click(); await consent.waitFor(); assert.equal(fixture.creates.length,0); await consent.getByRole('button',{name:'返回资料',exact:true}).click(); await creator(page).waitFor(); assert.deepEqual(await draftSnapshot(page),filledDraft); } checks.push(`${viewport.width}px: two forward/Back round trips preserve every unsaved field/avatar without creating an entity`); for(const dismissal of ['close','escape']) { await creator(page).getByRole('button',{name:'下一步',exact:true}).click(); await consent.waitFor(); if(dismissal==='escape') await page.keyboard.press('Escape'); else await consent.getByRole('button',{name:'关闭开通授权',exact:true}).click(); await consent.waitFor({state:'detached'}); assert.equal(fixture.creates.length,0); await openCreator(page); assert.deepEqual(await draftSnapshot(page),cleanDraft); if(dismissal==='close') await fillFullDraft(page); } checks.push(`${viewport.width}px: closing authorization with X/Escape abandons the entire unsaved creation, while Back keeps it`); for(const error of [false,true]) { const pending=await queueAvatar(page,`discarded-${error}.png`); await creator(page).getByRole('button',{name:'取消',exact:true}).click(); await creator(page).waitFor({state:'detached'}); await openCreator(page); await finishAvatar(page,pending,'red',error); assert.deepEqual(await draftSnapshot(page),cleanDraft); } checks.push(`${viewport.width}px: delayed avatar success/failure after cancellation cannot revive a discarded image/error`); await context.close(); } { const {page,context,fixture}=await fixturePage(); await installAvatarJobs(page); await openCreator(page); const cleanDraft=await draftSnapshot(page); const filledDraft=await fillFullDraft(page); const pending=await queueAvatar(page,'pending-at-submit.png'); await creator(page).getByRole('button',{name:'下一步',exact:true}).click(); const consent=page.locator('dialog.creation-consent-dialog'); fixture.holdCreate=true; await consent.getByRole('button',{name:'暂不开通,完成创建',exact:true}).click(); await page.waitForFunction(()=>document.querySelector('dialog.creation-consent-dialog button[type=submit]')?.disabled); assert.equal(await consent.getByRole('button',{name:'关闭开通授权',exact:true}).isDisabled(),true); assert.equal(await consent.getByRole('button',{name:'返回资料',exact:true}).isDisabled(),true); await page.keyboard.press('Escape'); assert.equal(await consent.count(),1); await consent.locator('form').dispatchEvent('submit'); assert.equal(fixture.creates.length,1); assert.equal(fixture.creates[0].body.preferred_name,filledDraft.fields[0]); assert.deepEqual(fixture.creates[0].body.initial_consents.types,[]); if(evidenceDir) await page.screenshot({path:join(evidenceDir,'submitting-guard.png')}); fixture.releaseCreate(); await consent.waitFor({state:'detached'}); await page.getByRole('heading',{name:'合成新长者',exact:true}).waitFor(); assert.equal(fixture.subjects.A.filter(item=>item.id==='synthetic-created').length,1); await openCreator(page); await finishAvatar(page,pending,'red'); assert.deepEqual(await draftSnapshot(page),cleanDraft); await creator(page).getByRole('button',{name:'取消',exact:true}).click(); await creator(page).waitFor({state:'detached'}); await page.getByRole('heading',{name:'合成新长者',exact:true}).waitFor(); assert.equal(fixture.creates.length,1); assert.equal(fixture.subjects.A.filter(item=>item.id==='synthetic-created').length,1); checks.push('submission blocks X/Back/Escape and duplicate submit; success keeps exactly one saved entity, resets next creation and rejects stale avatar'); await context.close(); } { const {page,context,fixture}=await fixturePage(); for(let cycle=0;cycle<3;cycle++) { await openCreator(page); assert.equal(await page.evaluate(()=>document.documentElement.style.overflow),'hidden'); await wrapCheck(page,creator(page)); if(cycle===1) await creator(page).getByRole('button',{name:'取消',exact:true}).click(); else await page.keyboard.press('Escape'); await creator(page).waitFor({state:'detached'}); assert.equal(await addButton(page).evaluate(element=>element===document.activeElement),true); assert.equal(await page.evaluate(()=>document.documentElement.style.overflow),''); } assert.equal(fixture.creates.length,0); checks.push('389px: repeated create/cancel/Escape, Tab/Shift+Tab loop, opener focus, scroll unlock'); await openCreator(page); await fillCreator(page); const bounds=await creator(page).boundingBox(); assert.ok(bounds.x>=0&&bounds.x+bounds.width<=390); await creator(page).getByRole('button',{name:'下一步',exact:true}).click(); const consent=page.locator('dialog.creation-consent-dialog'); await consent.waitFor(); assert.equal(await page.locator('dialog[open]').count(),1); assert.equal(await focusedInside(page),true); assert.equal(fixture.creates.length,0); await consent.getByRole('button',{name:'返回资料',exact:true}).click(); await creator(page).waitFor(); assert.equal(await creator(page).getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶').inputValue(),'合成新长者'); await page.keyboard.press('Escape'); assert.equal(await addButton(page).evaluate(element=>element===document.activeElement),true); checks.push('creation consent handoff has one modal and keeps focus; Back preserves draft and return focus'); for(const dismissal of ['escape','close']) { await openCreator(page); await fillCreator(page); await creator(page).getByRole('button',{name:'下一步',exact:true}).click(); await consent.waitFor(); if(dismissal==='escape') await page.keyboard.press('Escape'); else await consent.getByRole('button',{name:'关闭开通授权',exact:true}).click(); await consent.waitFor({state:'detached'}); assert.equal(await addButton(page).evaluate(element=>element===document.activeElement),true); assert.equal(fixture.creates.length,0); } checks.push('cancel/Escape from consent restores the original add-elder opener; no background body focus'); await openCreator(page); await fillCreator(page); await switchFamily(page,'B'); await creator(page).waitFor({state:'detached'}); await openCreator(page); assert.equal(await creator(page).getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶').inputValue(),''); checks.push('household change closes creation and clears old household draft'); await context.close(); } { const {page,context,fixture}=await fixturePage(); await installAvatarJobs(page); await openCreator(page); const oldFamilyJob=await queueAvatar(page,'old-family.png'); await switchFamily(page,'B'); await creator(page).waitFor({state:'detached'}); await openCreator(page); const currentFamilyJob=await queueAvatar(page,'current-family.png'); await finishAvatar(page,currentFamilyJob,'blue'); const currentFamilyAvatar=await avatarSource(page); await finishAvatar(page,oldFamilyJob,'red'); assert.equal(await avatarSource(page),currentFamilyAvatar); checks.push('a delayed avatar success from household A cannot replace household B draft image'); const oldTokenJob=await queueAvatar(page,'old-token.png'); await page.evaluate(()=>window.changeTestToken()); await creator(page).waitFor({state:'detached'}); await openCreator(page); const currentTokenJob=await queueAvatar(page,'current-token.png'); await finishAvatar(page,currentTokenJob,'green'); const currentTokenAvatar=await avatarSource(page); await finishAvatar(page,oldTokenJob,'red',true); assert.equal(await avatarSource(page),currentTokenAvatar); assert.equal(await creator(page).locator('.subject-avatar-error').count(),0); checks.push('a delayed avatar failure from an old token cannot clear the new image or show an old error'); const earlierJob=await queueAvatar(page,'earlier-selection.png'); const laterJob=await queueAvatar(page,'later-selection.png'); await finishAvatar(page,laterJob,'blue'); const latestAvatar=await avatarSource(page); await finishAvatar(page,earlierJob,'red'); assert.equal(await avatarSource(page),latestAvatar); const beforeRoundTrip=await queueAvatar(page,'before-family-round-trip.png'); await switchFamily(page,'A'); await creator(page).waitFor({state:'detached'}); await page.getByRole('heading',{name:'测试长者A',exact:true}).waitFor(); await switchFamily(page,'B'); await page.getByRole('heading',{name:'测试长者B',exact:true}).waitFor(); await openCreator(page); await finishAvatar(page,beforeRoundTrip,'red'); assert.equal(await creator(page).locator('.subject-avatar-upload img').count(),0); assert.equal(fixture.creates.length,0); checks.push('latest image selection wins; leaving and returning to a household does not revive an old avatar'); await context.close(); } { const {page,context,fixture}=await fixturePage(); fixture.holdCreate=true; await openCreator(page); await fillCreator(page); await creator(page).getByRole('button',{name:'下一步',exact:true}).click(); const consent=page.locator('dialog.creation-consent-dialog'); await consent.getByRole('button',{name:'暂不开通,完成创建',exact:true}).click(); await page.waitForFunction(()=>document.querySelector('dialog.creation-consent-dialog button[type=submit]')?.disabled); await page.keyboard.press('Escape'); assert.equal(await consent.count(),1); await consent.locator('form').dispatchEvent('submit'); assert.equal(fixture.creates.length,1); assert.deepEqual(fixture.creates[0].body.initial_consents.types,[]); await switchFamily(page,'B'); await consent.waitFor({state:'detached'}); fixture.releaseCreate(); await page.getByRole('heading',{name:'测试长者B',exact:true}).waitFor(); await openCreator(page); // Waits for busy to clear; old response must not reopen its scope. assert.equal(await creator(page).getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶').inputValue(),''); assert.doesNotMatch(await page.locator('body').innerText(),/长者档案已保存/); assert.equal(fixture.creates.length,1); checks.push('busy create is single-submit; deferred A response does not select an A elder or announce success in B'); await context.close(); } { const {page,context,fixture}=await fixturePage(); await openCreator(page); await fillCreator(page); await creator(page).getByRole('button',{name:'下一步',exact:true}).click(); await page.locator('dialog.creation-consent-dialog').waitFor(); await page.evaluate(()=>window.changeTestToken()); await page.locator('dialog.creation-consent-dialog').waitFor({state:'detached'}); await openCreator(page); assert.equal(await creator(page).getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶').inputValue(),''); assert.equal(fixture.creates.length,0); checks.push('account token change closes consent and clears unsubmitted creation data'); await context.close(); } { const {page,context,fixture}=await fixturePage('members'); const entry=page.getByRole('button',{name:/编辑名称/}); for(let cycle=0;cycle<2;cycle++) { await entry.click(); const dialog=page.locator('dialog.household-name-dialog'); await dialog.waitFor(); await wrapCheck(page,dialog); await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'}); assert.equal(await entry.evaluate(element=>element===document.activeElement),true); } await entry.click(); const dialog=page.locator('dialog.household-name-dialog'); await dialog.locator('input').fill('改名合成家庭'); fixture.holdRename=true; await dialog.getByRole('button',{name:'保存',exact:true}).click(); await page.keyboard.press('Escape'); assert.equal(await dialog.count(),1); await dialog.locator('form').dispatchEvent('submit'); assert.equal(fixture.renames.length,1); assert.equal(fixture.renames[0].ifMatch,'"v1"'); fixture.releaseRename(); await dialog.waitFor({state:'detached'}); assert.equal(await entry.evaluate(element=>element===document.activeElement),true); checks.push('household rename shares focus behavior, preserves busy guard and If-Match, returns opener after save'); await context.close(); } assert.deepEqual(errors,[]); assert.deepEqual(blocked,[]); const result={ok:true,viewports:['389x844','1188x761'],checks,blockedExternal:blocked,pageErrors:errors, isolation:{freshEmptyViteEnv:true,syntheticApiOnly:true,productionRequests:0,deployed:false}}; if(evidenceDir) await writeFile(join(evidenceDir,'browser-results.json'),JSON.stringify(result,null,2)+'\n'); console.log(JSON.stringify(result,null,2)); } finally { await browser.close(); await server.close(); if(dirname(work)!==resolve(tmpdir())) throw new Error('Unsafe fixture cleanup path'); await rm(work,{recursive:true,force:true}); }