Files
story/test/e2e/subject-roster-loading.mjs
T
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
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.
2026-10-08 10:06:44 +08:00

142 lines
10 KiB
JavaScript

import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtemp, mkdir, rm } 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-roster-loading-')); 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:'subject-roster-fixture',
configureServer(vite){vite.middlewares.use('/__roster-test',(_request,response)=>{
response.setHeader('Content-Type','text/html');
response.end('<!doctype html><meta charset="UTF-8"><div id="root"></div><script type="module" src="/@id/__roster-test.tsx"></script>');
});},
resolveId(id){if(id==='__roster-test.tsx')return '\0__roster-test.tsx';},
load(id){if(id==='\0__roster-test.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('subjects');return React.createElement(SaaSWorkspace,{accounts,accessToken:'synthetic-only',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 context=await browser.newContext({viewport:{width:1165,height:747},serviceWorkers:'block'});
await context.addInitScript(()=>{
localStorage.setItem('companion:household:qa-tenant/qa-user','A');
const originalFetch=window.fetch.bind(window);
window.rosterResponses=[];
window.fetch=async(input,init)=>{
const url=new URL(typeof input==='string'?input:input.url,location.href);
if(!/^\/api\/v1\/households\/[^/]+\/subjects$/.test(url.pathname)||(init?.method??'GET')!=='GET')return originalFetch(input,init);
// Simulate a transport that still delivers an old read after cancellation.
// The production effect's own AbortController/scope guards must reject it.
const response=await originalFetch(input,{...init,signal:undefined});
window.rosterResponses.push((await response.clone().json()).request_id);
return response;
};
});
const page=await context.newPage();page.setDefaultTimeout(15000);
const errors=[],blocked=[],pending=[],checks=[];
const held=new Set();let sequence=0;
const capabilities=['household.read','household.manage','member.manage','ownership.transfer','subject.read','subject.write','privacy.read','privacy.manage','conversation.read','story.review'];
const households=['A','B'].map(id=>({id,name:`合成家庭${id}`,tenant_id:'qa-tenant',status:'active',role:'owner',capabilities,version:1}));
const subject=(id,suffix='')=>({id:`elder-${id}`,household_id:id,preferred_name:`合成长者${id}${suffix}`,language:'zh-CN',timezone:'Asia/Shanghai',profile_data:{background:'本地合成',interests:[]},conversation_preferences:{pace:'slow',conversation_goal:'日常陪伴',preferred_topics:[],avoided_topics:[]},status:'active',version:1});
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 answer=data=>route.fulfill({json:{ok:true,data,request_id:'synthetic-roster'},headers:{ETag:'"v1"'}});
if(path==='/api/v1/events')return route.fulfill({contentType:'text/event-stream',body:': synthetic read-only fixture\n\n'});
if(path==='/api/v1/households')return answer({items:households});
const roster=path.match(/^\/api\/v1\/households\/([^/]+)\/subjects$/);
if(roster&&method==='GET'){
const id=roster[1],requestID=`roster-${++sequence}`;
let result={items:[subject(id)]};
if(held.has(id))result=await new Promise(resolve=>pending.push({id,requestID,release:resolve,released:false}));
return result.error
?route.fulfill({status:502,json:{ok:false,error:{code:'SYNTHETIC_UNAVAILABLE',message:'合成名单读取失败'},request_id:requestID}})
:route.fulfill({json:{ok:true,data:{items:result.items},request_id:requestID}});
}
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'))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 mutation ${method} ${path}`);return route.fulfill({status:400,json:{ok:false}});
});
const nextRead=async id=>{
const deadline=Date.now()+10000;
while(Date.now()<deadline){const job=pending.find(item=>item.id===id&&!item.taken);if(job){job.taken=true;return job;}await new Promise(resolve=>setTimeout(resolve,10));}
throw new Error(`Missing synthetic held read for ${id}`);
};
const release=async(job,result)=>{
job.released=true;job.release(result);
await page.waitForFunction(id=>window.rosterResponses.includes(id),job.requestID);
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
};
const changeFamily=async id=>{
await page.getByRole('button',{name:'切换家庭',exact:true}).click();
await page.getByRole('menuitem',{name:`合成家庭${id}`,exact:true}).click();
};
const assertLoading=async()=>{
await page.getByText('正在读取长者资料',{exact:true}).waitFor();
const body=await page.locator('.cp-subject-page').innerText();
assert.doesNotMatch(body,/为家人添加第一份资料|添加长者|合成长者[AB]/);
};
const shown=async name=>page.getByRole('heading',{name,exact:true}).waitFor();
try{
await page.goto(origin+'/__roster-test');await shown('合成长者A');
await page.evaluate(()=>{
window.emptyRosterFrames=[];window.watchRosterEmpty=true;
const observer=new MutationObserver(()=>{
if(window.watchRosterEmpty&&document.querySelector('.cp-subject-page')?.textContent.includes('为家人添加第一份资料'))window.emptyRosterFrames.push(document.querySelector('.cp-subject-page').textContent);
});observer.observe(document.getElementById('root'),{childList:true,subtree:true,characterData:true});
});
held.add('B');await changeFamily('B');const delayedB=await nextRead('B');await assertLoading();
await release(delayedB,{items:[subject('B')]});await shown('合成长者B');
held.add('A');await changeFamily('A');const delayedA=await nextRead('A');await assertLoading();
await release(delayedA,{items:[subject('A')]});await shown('合成长者A');
checks.push('A → B and B → A delayed reads show loading, never first-profile creation guidance');
await changeFamily('B');const oldB=await nextRead('B');await assertLoading();
await changeFamily('A');const middleA=await nextRead('A');await assertLoading();
await changeFamily('B');const newestB=await nextRead('B');await assertLoading();
await release(newestB,{items:[subject('B','最新')]});await shown('合成长者B最新');
await release(middleA,{items:[]});await release(oldB,{items:[]});await shown('合成长者B最新');
checks.push('B → A → B: newer B result survives older A and B empty responses delivered after abort');
await changeFamily('A');const oldA=await nextRead('A');await assertLoading();
await changeFamily('B');const middleB=await nextRead('B');await assertLoading();
await changeFamily('A');const newestA=await nextRead('A');await assertLoading();
await release(newestA,{items:[subject('A','最新')]});await shown('合成长者A最新');
await release(middleB,{items:[]});await release(oldA,{items:[]});await shown('合成长者A最新');
assert.deepEqual(await page.evaluate(()=>window.emptyRosterFrames),[]);
checks.push('A → B → A: newer A result survives older B and A empty responses; observer records zero false-empty renders');
await page.evaluate(()=>window.watchRosterEmpty=false);
await changeFamily('B');const emptyB=await nextRead('B');await assertLoading();
await release(emptyB,{items:[]});await page.getByText('为家人添加第一份资料',{exact:true}).waitFor();
checks.push('first-profile guidance appears only after the current family read confirms an empty roster');
await changeFamily('A');const failedA=await nextRead('A');await assertLoading();
await release(failedA,{error:true});await page.getByText('长者资料暂时无法读取',{exact:true}).waitFor();
assert.doesNotMatch(await page.locator('.cp-subject-page').innerText(),/为家人添加第一份资料|添加长者/);
checks.push('a failed roster read is distinct from empty and does not suggest creating duplicate profiles');
assert.deepEqual(errors,[]);assert.deepEqual(blocked,[]);
console.log(JSON.stringify({ok:true,checks,falseEmptyRenders:0,blockedExternal:blocked},null,2));
}finally{
for(const job of pending)if(!job.released)job.release({items:[]});
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});
}