358 lines
25 KiB
JavaScript
358 lines
25 KiB
JavaScript
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('<!doctype html><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><div id="root"></div><script type="module" src="/@id/__dialog-fixture.tsx"></script>');
|
||
|
|
}); },
|
||
|
|
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});
|
||
|
|
}
|