feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
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});
|
||||
}
|
||||
Reference in New Issue
Block a user