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('
'); });}, 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()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}); }