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()