// Actual portal components and routing, deterministic dates and synthetic API // metadata only. Backend aggregation is tested separately against local PG. import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve, join, dirname } from 'node:path'; import { pathToFileURL } from 'node:url'; import { installCustomerFixture, households, subjects, devices, id } from './fixtures/customer-mobile.mjs'; 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-usage-boundaries-')); const output=resolve(process.env.USAGE_BOUNDARY_OUTPUT ?? '../.local-evidence/usage-chat-boundaries-20261006/browser'); await mkdir(join(work,'empty-env')); await mkdir(output,{recursive:true}); 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:'usage-boundary-fixture', configureServer(vite){vite.middlewares.use('/__usage-boundary',(_q,response)=>{response.setHeader('Content-Type','text/html');response.end('
');});}, resolveId(name){if(name==='__usage-boundary.tsx')return '\0__usage-boundary.tsx';}, load(name){if(name==='\0__usage-boundary.tsx')return `import React from 'react';import {createRoot} from 'react-dom/client';import {AuthProvider} from '/src/auth/useAuth.ts';import {CustomerPortal} from '/src/pages/CustomerPortal.tsx';import '/src/styles.css';import '/src/cal-theme.css';import '/src/customer-responsive.css';createRoot(document.getElementById('root')).render(React.createElement(AuthProvider,null,React.createElement(CustomerPortal)));`;}, }]}); const now='2026-10-06T04:00:00.000Z', familyA=households[0],familyB=households[1]; const roster=[...subjects.slice(0,2),{...subjects[2],id:id(410),household_id:familyB.id,preferred_name:'合成空家庭长者'}]; const fleet=devices.slice(0,2).map((d,index)=>({...d,model:`合成设备${index+1}`})); const iso=ms=>new Date(ms).toISOString(); const beijingDay=at=>iso(Date.parse(at)+8*3600000).slice(0,10); const mainStarts={30:'2026-09-06T16:00:00.000Z',90:'2026-07-08T16:00:00.000Z',365:'2025-10-06T16:00:00.000Z'}; const usageRows=[]; function addUsage(at,deviceIndex){const device=fleet[deviceIndex],duration=(deviceIndex+1)*60000;usageRows.push({id:id(2100+usageRows.length),household_id:familyA.id,device_id:device.id,subject_name:device.subject_name,device_name:device.model,started_at:at,ended_at:iso(Date.parse(at)+duration),duration_ms:duration,status:'COMPLETED'});} addUsage('2026-10-05T15:59:59.999Z',0);addUsage('2026-10-05T16:00:00.000Z',1); for(let i=0;i<23;i++)addUsage(iso(Date.parse('2026-10-05T10:00:00Z')-i*60000),i%2); for(const [days,deviceIndex] of [[30,0],[90,1],[365,0]]){addUsage(iso(Date.parse(mainStarts[days])-1),1-deviceIndex);addUsage(mainStarts[days],deviceIndex);} addUsage(now,0);addUsage(iso(Date.parse(now)+1),1); const legacyRows=[]; function addLegacy(at,subjectID=subjects[0].id){legacyRows.push({id:id(3000+legacyRows.length),subject_id:subjectID,device_id:fleet[0].id,created_at:at,updated_at:at,started_at:at,ended_at:iso(Date.parse(at)+60000),duration_ms:60000,status:'FAILED',engine_mode:'s2s_hybrid',runtime_type:'legacy_voicechat',retention_mode:'none'});} for(let i=0;i<35;i++)addLegacy(iso(Date.parse('2026-10-06T03:00:00Z')-i*60000)); for(const at of ['2026-10-05T15:59:59.999Z','2026-10-05T16:00:00.000Z','2026-10-06T15:59:59.999Z','2026-10-06T16:00:00.000Z'])addLegacy(at); for(let i=0;i<16;i++)addLegacy(iso(Date.parse('2026-10-06T02:00:00Z')-i*60000),subjects[1].id); const modernRows=roster.flatMap((s,index)=>Array.from({length:index===0?55:index===1?21:0},(_,i)=>({id:id(4000+index*100+i),elder_id:s.id,business_session_id:id(4000+index*100+i),created_at:iso(Date.parse('2026-10-06T03:00:00Z')-i*60000),status:'ENDED',transcript_state:'EMPTY',duration_ms:60000}))); const reads=[],mutations=[],external=[],textRequests=[],errors=[],results=[]; let browser,origin; function aggregate(household,params){ const deviceID=params.get('device_id'),from=Date.parse(params.get('from')),to=Date.parse(params.get('to')); if(deviceID)assert(fleet.some(d=>d.id===deviceID&&d.household_id===household),'foreign device reached usage request'); const rows=usageRows.filter(r=>r.household_id===household&&(!deviceID||r.device_id===deviceID)&&Date.parse(r.started_at)>=from&&Date.parse(r.started_at)b.started_at.localeCompare(a.started_at)); const daily=new Map(),byDevice=new Map(); for(const r of rows){const date=beijingDay(r.started_at),d=daily.get(date)??{date,duration_ms:0,conversation_count:0,cost_micros:0};d.duration_ms+=r.duration_ms;d.conversation_count++;daily.set(date,d);const item=byDevice.get(r.device_id)??{id:r.device_id,name:r.device_name,conversation_count:0,duration_ms:0,cost_micros:0};item.duration_ms+=r.duration_ms;item.conversation_count++;byDevice.set(r.device_id,item);} const total=rows.reduce((sum,r)=>sum+r.duration_ms,0); return{from:params.get('from'),to:params.get('to'),timezone:'Asia/Shanghai',scope:{household_id:household,household_name:households.find(h=>h.id===household).name,...(deviceID?{device_id:deviceID}:{})},total_duration_ms:total,average_duration_ms:rows.length?Math.floor(total/rows.length):0,conversation_count:rows.length,daily:[...daily.values()].sort((a,b)=>a.date.localeCompare(b.date)),devices:[...byDevice.values()],recent_conversations:rows.slice(0,20),currency:'CNY',cost_available:false}; } async function start(width,section='billing',instant=now,timezoneId='America/Los_Angeles'){ const context=await browser.newContext({viewport:{width,height:844},timezoneId,serviceWorkers:'block'}); const unexpected=await installCustomerFixture(context); const page=await context.newPage();page.setDefaultTimeout(10000);await page.clock.setFixedTime(new Date(instant));page.on('pageerror',e=>errors.push(e.message)); const control={holdLegacyMore:false,holdUsage:false,pending:[],usageAnswers:[]}; await page.route('**/*',async route=>{ const request=route.request(),url=new URL(request.url()),path=url.pathname; if(url.origin!==origin){external.push(url.origin+path);return route.abort();} if(!path.startsWith('/api/'))return route.continue(); const answer=data=>route.fulfill({json:{data,request_id:'synthetic-boundary'}}); if(path==='/api/v1/memories/calls/query'&&request.method()==='POST'){ const body=request.postDataJSON(),offset=body.cursor?Number(body.cursor.split(':').at(-1)):0; reads.push({kind:'modern',...body});const all=modernRows.filter(r=>r.elder_id===body.elder_id),limit=body.limit; return answer({items:all.slice(offset,offset+limit),next_cursor:all.length>offset+limit?`${body.elder_id}:${offset+limit}`:''}); } if(/\/(turns|summaries)$|\/calls\/turns$/.test(path)){textRequests.push(path);return route.abort();} if(request.method()!=='GET'){mutations.push(request.method()+' '+path);return route.abort();} if(path==='/api/v1/devices')return answer({items:fleet}); const subjectMatch=path.match(/\/households\/([^/]+)\/subjects$/); if(subjectMatch)return answer({items:roster.filter(s=>s.household_id===subjectMatch[1])}); const usage=path.match(/\/households\/([^/]+)\/usage-overview$/); if(usage){ reads.push({kind:'usage',householdID:usage[1],...Object.fromEntries(url.searchParams)}); const result=aggregate(usage[1],url.searchParams);control.usageAnswers.push(result); if(control.holdUsage){control.holdUsage=false;await new Promise(release=>control.pending.push(release));} return answer(result); } const legacy=path.match(/\/subjects\/([^/]+)\/conversations$/); if(legacy){ const params=url.searchParams,offset=params.has('cursor')?Number(params.get('cursor').split(':').at(-1)):0,limit=Number(params.get('limit')); reads.push({kind:'legacy',subjectID:legacy[1],...Object.fromEntries(params)}); const all=legacyRows.filter(r=>r.subject_id===legacy[1]&&(!params.has('from')||Date.parse(r.created_at)>=Date.parse(params.get('from')))&&(!params.has('to')||Date.parse(r.created_at)b.created_at.localeCompare(a.created_at)); if(control.holdLegacyMore&¶ms.has('cursor')){control.holdLegacyMore=false;await new Promise(release=>control.pending.push(release));} return answer({items:all.slice(offset,offset+limit),next_cursor:all.length>offset+limit?`${legacy[1]}:${offset+limit}`:''}); } if(/\/(stories|memories|facts|events\/history)$/.test(path))return answer({items:[]}); if(path.endsWith('/home-overview'))return answer({daily:[],conversation_count:0,total_duration_ms:0,week_start:'2026-10-05',week_end:'2026-10-12'}); return route.fallback(); }); await page.goto(origin+'/__usage-boundary?section='+section); return{page,context,control,unexpected}; } const usageDevice=page=>page.locator('.cp-usage-filters label select'); const usagePeriod=page=>page.getByRole('combobox',{name:'用量时间范围',exact:true}); async function usageReady(page,count){await page.locator('.cp-usage-metrics').getByText(String(count)+' 次',{exact:true}).waitFor();} async function firstPage(page,label){await page.locator(`[aria-label="${label}第 1 页"][aria-current="page"]`).waitFor({state:'attached'});} async function openEarly(page){ // Wait for the initial response before opening this native details element. await page.locator('.conversation-filter__summary').getByText(/^已加载 20 条/).waitFor({state:'attached'}); const summary=page.locator('summary').filter({hasText:'查看早期聊天记录'}); if(!await summary.evaluate(element=>element.parentElement.open))await summary.click(); await page.getByRole('navigation',{name:'聊天时间线分页',exact:true}).waitFor(); } async function nav(page,label,width){if(width<900)await page.getByRole('button',{name:'打开导航菜单',exact:true}).click();await page.getByRole('link',{name:label,exact:true}).click();} async function switchFamily(page,width){if(width<900)await page.getByRole('button',{name:'打开导航菜单',exact:true}).click();await page.getByRole('button',{name:'切换家庭',exact:true}).click();await page.getByRole('menuitem',{name:familyB.name,exact:true}).click();if(width<900)await page.locator('.ch-mobile-drawer .ant-drawer-close').click();} async function check(name,run){try{await run();results.push({name,status:'passed'});}catch(error){results.push({name,status:'failed',error:error.message});}console.log(JSON.stringify(results.at(-1)));} async function withPage(width,section,run,instant=now,timezone){const state=await start(width,section,instant,timezone);try{await run(state);}catch(error){await state.page.screenshot({path:join(output,`failure-${results.length}-${width}.png`)});await writeFile(join(output,`failure-${results.length}-${width}.txt`),await state.page.locator('body').innerText());throw error;}finally{state.control.pending.splice(0).forEach(release=>release());await state.context.close();assert.deepEqual(state.unexpected,[]);}} try{ await server.listen();origin=`http://127.0.0.1:${server.httpServer.address().port}`; browser=await chromium.launch({headless:true,...(process.env.CHROME_EXECUTABLE?{executablePath:process.env.CHROME_EXECUTABLE}:{})}); for(const width of [1188,389]){ await check(`${width}px usage: 30/90/365 boundaries, two independent devices and all-device totals, paging/filter reset`,()=>withPage(width,'billing',async({page,control})=>{ await usageReady(page,26);assert.match(await page.locator('.cp-usage-metrics').innerText(),/38\s*分钟/); assert.match(await page.locator('.cp-table tbody tr').nth(0).innerText(),/10\/6.*00:00/); assert.match(await page.locator('.cp-table tbody tr').nth(1).innerText(),/10\/5.*23:59/); await page.getByRole('button',{name:'最近通话下一页',exact:true}).click(); assert.equal(await page.locator('.cp-table tbody tr').count(),10); await usageDevice(page).selectOption(fleet[0].id);await usageReady(page,14);await firstPage(page,'最近通话'); assert.match(await page.locator('.cp-usage-metrics').innerText(),/14\s*分钟/); assert((await page.locator('.cp-table tbody').innerText()).includes('合成设备1'));assert(!(await page.locator('.cp-table tbody').innerText()).includes('合成设备2')); await page.getByRole('button',{name:'最近通话下一页',exact:true}).click();assert.equal(await page.locator('.cp-table tbody tr').count(),4); await usageDevice(page).selectOption(fleet[1].id);await usageReady(page,12);await firstPage(page,'最近通话'); assert.match(await page.locator('.cp-usage-metrics').innerText(),/24\s*分钟/); await usageDevice(page).selectOption('');await usageReady(page,26);await firstPage(page,'最近通话'); const answer=control.usageAnswers.at(-1);assert.equal(answer.total_duration_ms,2280000);assert.equal(answer.devices.length,2);assert.equal(answer.recent_conversations.length,20); assert.equal(answer.daily.find(d=>d.date==='2026-10-06').conversation_count,1,'exact midnight starts the new Beijing date'); assert.equal(answer.daily.find(d=>d.date==='2026-10-05').conversation_count,24,'pre-midnight session belongs to previous date'); const periodCounts={30:26,90:28,365:30}; for(const days of [90,365,30]){await page.getByRole('button',{name:'最近通话下一页',exact:true}).click();await usagePeriod(page).selectOption(String(days));await usageReady(page,periodCounts[days]);await firstPage(page,'最近通话');const request=reads.filter(r=>r.kind==='usage').at(-1);assert.equal(request.from,mainStarts[days]);assert.equal(request.to,now);assert.equal(request.timezone,'Asia/Shanghai');} await page.getByRole('combobox',{name:'最近通话每页条数',exact:true}).selectOption('5');await firstPage(page,'最近通话');assert.equal(await page.locator('.cp-table tbody tr').count(),5); if(width>=640){await page.getByRole('textbox',{name:'最近通话跳转页码',exact:true}).fill('4');await page.getByRole('button',{name:'最近通话跳转',exact:true}).click();} else for(let i=0;i<3;i++)await page.getByRole('button',{name:'最近通话下一页',exact:true}).click(); assert.equal(await page.locator('[aria-current=page][aria-label="最近通话第 4 页"]').count(),1); await usagePeriod(page).selectOption('90');await usageReady(page,28);await firstPage(page,'最近通话'); await page.screenshot({path:join(output,`usage-boundaries-${width}.png`)}); })); await check(`${width}px usage: empty family clears selected device/results and ignores a delayed old-family response`,()=>withPage(width,'billing',async({page,control})=>{ await usageReady(page,26);await usageDevice(page).selectOption(fleet[0].id);await usageReady(page,14); control.holdUsage=true;await page.getByRole('button',{name:'刷新',exact:true}).click();await page.waitForFunction(()=>document.querySelector('.cp-usage-metrics')?.textContent.includes('—')); await switchFamily(page,width);await usageReady(page,0);assert.equal(await usageDevice(page).inputValue(),'');assert.equal(await usageDevice(page).locator('option').count(),1); control.pending.splice(0).forEach(release=>release());await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); await usageReady(page,0);assert.equal(await page.locator('.cp-table tbody tr').count(),0);assert.equal(await page.getByRole('group',{name:'每日聊天用量',exact:true}).count(),0); assert(!reads.filter(r=>r.kind==='usage'&&r.householdID===familyB.id).some(r=>r.device_id));await page.screenshot({path:join(output,`empty-family-${width}.png`)}); })); await check(`${width}px device chats: API cursor, local page/size, elder switch, refresh and empty family reset`,()=>withPage(width,'conversations',async({page})=>{ const pager=page.getByRole('navigation',{name:'聊天记录分页',exact:true});await pager.getByText('30',{exact:true}).waitFor();assert.equal(await page.locator('.cp-memory-row').count(),10); await pager.getByRole('button',{name:'聊天记录下一页',exact:true}).click();await page.getByRole('combobox',{name:'聊天记录每页条数',exact:true}).selectOption('5');await firstPage(page,'聊天记录');assert.equal(await page.locator('.cp-memory-row').count(),5); await pager.getByRole('button',{name:/^读取后续记录/}).click();await pager.getByText('55',{exact:true}).waitFor();assert.equal(await pager.getByRole('button',{name:/^读取后续记录/}).count(),0); await pager.getByRole('button',{name:'聊天记录下一页',exact:true}).click();await page.locator('.cp-subject-picker .cp-elder-roster button').filter({hasText:subjects[1].preferred_name}).click();await pager.getByText('21',{exact:true}).waitFor();await firstPage(page,'聊天记录'); await pager.getByRole('button',{name:'聊天记录下一页',exact:true}).click();await page.getByRole('button',{name:'刷新记录',exact:true}).click();await firstPage(page,'聊天记录'); await switchFamily(page,width);await page.locator('.cp-elder-roster button[aria-pressed=true]').filter({hasText:roster[2].preferred_name}).waitFor();await page.locator('section[aria-label="设备聊天记录"]').getByRole('heading',{name:'还没有聊天记录',exact:true}).waitFor();assert.equal(await page.locator('.cp-memory-row').count(),0);assert.equal(await pager.count(),0); })); await check(`${width}px early chats: inclusive Beijing date inputs, exclusive next midnight, paging/filter reset`,()=>withPage(width,'conversations',async({page})=>{ await openEarly(page);const pager=page.getByRole('navigation',{name:'聊天时间线分页',exact:true});await pager.getByText('20',{exact:true}).waitFor(); await pager.getByRole('button',{name:/^读取后续记录/}).click();await pager.getByText('39',{exact:true}).waitFor();await pager.getByRole('button',{name:'聊天时间线下一页',exact:true}).click(); await page.getByRole('button',{name:'筛选日期与状态',exact:true}).click();const dates=page.locator('.conversation-filter input[type=date]');await dates.nth(0).fill('2026-10-06');await dates.nth(1).fill('2026-10-06');await pager.getByText('20',{exact:true}).waitFor();await firstPage(page,'聊天时间线'); const query=reads.filter(r=>r.kind==='legacy').at(-1);assert.equal(query.from,'2026-10-05T16:00:00.000Z');assert.equal(query.to,'2026-10-06T16:00:00.000Z');assert(!query.cursor); await pager.getByRole('button',{name:/^读取后续记录/}).click();await pager.getByText('37',{exact:true}).waitFor(); const expected=legacyRows.filter(r=>r.subject_id===subjects[0].id&&r.created_at>=query.from&&r.created_atr.created_at==='2026-10-06T16:00:00.000Z')); await pager.getByRole('button',{name:'聊天时间线下一页',exact:true}).click();await page.locator('.conversation-filter').getByLabel(/^通话状态/).selectOption('ACTIVE');await page.locator('.conversation-filter__summary').getByText('已加载 0 条',{exact:true}).waitFor();assert.equal(await page.locator('.conversation-row').count(),0); await page.getByRole('button',{name:'清除筛选',exact:true}).click();await pager.getByText('20',{exact:true}).waitFor();await firstPage(page,'聊天时间线');assert.deepEqual(await dates.evaluateAll(nodes=>nodes.map(n=>n.value)),['','']); await page.screenshot({path:join(output,`early-chat-dates-${width}.png`)}); })); await check(`${width}px early chats: delayed cursor page cannot leak into an empty different family`,()=>withPage(width,'conversations',async({page,control})=>{ await openEarly(page);const pager=page.getByRole('navigation',{name:'聊天时间线分页',exact:true});await pager.getByText('20',{exact:true}).waitFor(); control.holdLegacyMore=true;await pager.getByRole('button',{name:/^读取后续记录/}).click(); await page.waitForFunction(()=>document.querySelector('nav[aria-label="聊天时间线分页"]')?.getAttribute('aria-busy')==='true');assert.equal(control.pending.length,1); await switchFamily(page,width);await page.locator('.cp-elder-roster button[aria-pressed=true]').filter({hasText:roster[2].preferred_name}).waitFor();await page.locator('.conversation-filter__summary').getByText('已加载 0 条',{exact:true}).waitFor({state:'attached'}); const delivered=page.waitForResponse(response=>/\/subjects\/[^/]+\/conversations/.test(response.url())&&new URL(response.url()).searchParams.has('cursor')); control.pending.splice(0).forEach(release=>release());await (await delivered).finished(); await page.waitForFunction(()=>!document.querySelector('.conversation-filter select:disabled')); await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); assert.equal(await page.locator('.conversation-row').count(),0,'old-family cursor response revived conversation rows'); assert.match(await page.locator('.conversation-filter__summary').innerText(),/已加载 0 条/); })); } for(const [instant,starts] of [ ['2026-10-05T15:59:59.999Z',{30:'2026-09-05T16:00:00.000Z',90:'2026-07-07T16:00:00.000Z',365:'2025-10-05T16:00:00.000Z'}], ['2026-10-05T16:00:00.000Z',mainStarts],['2026-10-05T16:00:00.001Z',mainStarts], ])await check(`Beijing midnight ${instant}: actual 30/90/365 request bounds in UTC browser`,()=>withPage(1188,'billing',async({page,control})=>{ for(const days of [30,90,365]){await usagePeriod(page).waitFor();if(days!==30)await usagePeriod(page).selectOption(String(days));await page.waitForFunction(()=>!document.querySelector('.cp-usage-filters button')?.disabled);const response=control.usageAnswers.at(-1);assert.equal(response.from,starts[days]);assert.equal(response.to,instant);assert.equal(response.timezone,'Asia/Shanghai');} },instant,'Etc/UTC')); assert.deepEqual(mutations,[]);assert.deepEqual(external,[]);assert.deepEqual(textRequests,[]);assert.deepEqual(errors,[]); }finally{ const result={passed:results.every(r=>r.status==='passed'),results,reads,mutations,external,textRequests,browserErrors:errors,isolation:{fixedSyntheticDates:true,freshEmptyViteEnv:true,productionRequests:0,businessMutations:0},scope:'Browser fixtures exercise UI and request wiring; actual backend SQL is covered by the separate Go/PostgreSQL test.'}; await writeFile(join(output,'checks.json'),JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify({...result,reads:reads.length},null,2)); await browser?.close();await server.close();assert.equal(dirname(work),resolve(tmpdir()));await rm(work,{recursive:true,force:true}); } if(results.some(r=>r.status==='failed'))process.exitCode=1;