Files

190 lines
23 KiB
JavaScript
Raw Permalink Normal View History

// 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('<!doctype html><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><div id="root"></div><script type="module" src="/@id/__usage-boundary.tsx"></script>');});},
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)<to).sort((a,b)=>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)<Date.parse(params.get('to')))&&(!params.has('status')||r.status===params.get('status'))&&(!params.has('engine_mode')||r.engine_mode===params.get('engine_mode'))).sort((a,b)=>b.created_at.localeCompare(a.created_at));
if(control.holdLegacyMore&&params.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_at<query.to);assert.equal(expected.length,37);assert(!expected.some(r=>r.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;