Files
story/test/e2e/subject-roster-loading.mjs
T

142 lines
10 KiB
JavaScript
Raw Normal View History

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});
}