// Actual portal and routing. Only synthetic metadata is returned; conversation // text, external requests, and business mutations are blocked. 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 } 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-chat-')); const output = resolve(process.env.USAGE_CHAT_OUTPUT ?? '../.local-evidence/usage-chat-context-20261006'); 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-chat-fixture', configureServer(vite) { vite.middlewares.use('/__usage-context', (_q, response) => { response.setHeader('Content-Type', 'text/html'); response.end('
'); }); }, resolveId(name) { if (name === '__usage-context.tsx') return '\0__usage-context.tsx'; }, load(name) { if (name === '\0__usage-context.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 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), { ...devices[2], id: id(520), household_id: familyB.id, subject_id: id(410), subject_name: roster[2].preferred_name }, { ...devices[0], id: id(530), subject_id: undefined, subject_name: '尚未绑定' }]; const reads = [], mutations = [], external = [], textRequests = [], errors = [], checks = []; let browser, origin; async function start(viewport, query = '?section=billing') { const context = await browser.newContext({ viewport, serviceWorkers: 'block' }); const unexpected = await installCustomerFixture(context); const page = await context.newPage(); page.setDefaultTimeout(10000); 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; 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-usage' } }); if (path === '/api/v1/memories/calls/query' && request.method() === 'POST') { const body = request.postDataJSON(); reads.push({ kind: 'calls', ...body }); const count = body.elder_id === subjects[1].id ? 13 : body.elder_id === id(410) ? 2 : 0; return answer({ next_cursor: '', items: Array.from({ length: count }, (_, i) => ({ id: id(1600 + i), elder_id: body.elder_id, business_session_id: id(1600 + i), created_at: new Date(Date.now() - i * 86400000).toISOString(), status: 'ENDED', transcript_state: 'EMPTY', duration_ms: i ? 70000 : 66000 })) }); } if (/\/calls\/turns$|\/turns$|\/summaries$/.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 householdSubjects = path.match(/\/households\/([^/]+)\/subjects$/); if (householdSubjects) { reads.push({ kind: 'roster', householdID: householdSubjects[1] }); return answer({ items: roster.filter(s => s.household_id === householdSubjects[1]) }); } const usage = path.match(/\/households\/([^/]+)\/usage-overview$/); if (usage) { const deviceID = url.searchParams.get('device_id'); reads.push({ kind: 'usage', householdID: usage[1], deviceID }); const owner = fleet.find(d => d.id === deviceID && d.household_id === usage[1]); if (deviceID && !owner) throw new Error('Cross-family device leaked into a usage request'); const count = owner?.subject_id === subjects[1].id ? 13 : 0; const recent = Array.from({ length: count }, (_, i) => ({ id: id(1600 + i), started_at: new Date(Date.now() - i * 86400000).toISOString(), ended_at: new Date(Date.now() - i * 86400000 + (i ? 70000 : 66000)).toISOString(), subject_name: owner.subject_name, device_name: owner.model, duration_ms: i ? 70000 : 66000, status: 'ENDED' })); return answer({ from: url.searchParams.get('from'), to: url.searchParams.get('to'), timezone: 'Asia/Shanghai', scope: { household_name: households.find(h => h.id === usage[1])?.name }, total_duration_ms: count ? 906000 : 0, average_duration_ms: count ? 906000 / count : 0, conversation_count: count, cost_available: false, currency: 'CNY', daily: recent.map(item => ({ date: item.started_at.slice(0, 10), duration_ms: item.duration_ms, conversation_count: 1, cost_micros: 0 })), devices: [], recent_conversations: recent }); } if (/\/conversations$|\/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-context' + query); return { page, context, unexpected }; } async function nav(page, label, narrow = false) { if (narrow) await page.getByRole('button', { name: '打开导航菜单', exact: true }).click(); await page.getByRole('link', { name: label, exact: true }).click(); } async function selected(page, name) { await page.locator(`.cp-elder-roster button[aria-pressed="true"]`).filter({ hasText: name }).waitFor(); } const deviceControl = page => page.locator('.cp-usage-filters label select'); async function usage(page) { await deviceControl(page).waitFor(); } 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]) { const { page, context, unexpected } = await start({ width, height: 844 }); try { await usage(page); await deviceControl(page).selectOption(devices[1].id); await page.locator('.cp-usage-metrics').getByText('15.1', { exact: false }).waitFor(); const link = page.getByRole('link', { name: '查看全部聊天记录 →', exact: true }); const target = new URL(await link.getAttribute('href'), origin); assert.equal(target.searchParams.get('household_id'), familyA.id); assert.equal(target.searchParams.get('subject_id'), subjects[1].id); assert.equal(target.searchParams.get('usage_device_id'), devices[1].id); assert(!target.searchParams.has('from') && !target.searchParams.has('to')); await link.scrollIntoViewIfNeeded(); await page.screenshot({ path: resolve(output, `usage-${width}.png`) }); await link.click(); await page.waitForURL(url => url.searchParams.get('section') === 'conversations'); await selected(page, subjects[1].preferred_name); await page.getByRole('navigation', { name: '聊天记录分页', exact: true }).getByText('13', { exact: true }).waitFor(); assert.equal(await page.locator('.cp-memory-row').count(), 10, 'first page of 13 records uses the existing pagination'); await page.screenshot({ path: resolve(output, `conversations-${width}.png`) }); await page.reload(); await selected(page, subjects[1].preferred_name); await page.getByRole('navigation', { name: '聊天记录分页', exact: true }).getByText('13', { exact: true }).waitFor(); await page.goBack(); await usage(page); assert.equal(await deviceControl(page).inputValue(), devices[1].id); await page.goForward(); await selected(page, subjects[1].preferred_name); await page.goBack(); await usage(page); // Reverse comparison: a pre-existing manually selected elder must survive. await nav(page, '聊天记录', width < 900); await selected(page, subjects[1].preferred_name); await page.locator('.cp-subject-picker .cp-elder-roster button').filter({ hasText: subjects[0].preferred_name }).click(); await page.locator('.cp-subject-picker .cp-elder-roster button').filter({ hasText: subjects[1].preferred_name }).click(); await selected(page, subjects[1].preferred_name); await page.locator('summary').filter({ hasText: '查看早期聊天记录' }).click(); await page.getByRole('button', { name: '筛选日期与状态', exact: true }).click(); await page.locator('.conversation-filter input[type="date"]').nth(0).fill('2001-01-01'); await page.locator('.conversation-filter input[type="date"]').nth(1).fill('2001-02-01'); await nav(page, '服务用量', width < 900); await usage(page); await deviceControl(page).selectOption(devices[1].id); await page.getByRole('link', { name: '查看全部聊天记录 →', exact: true }).click(); await selected(page, subjects[1].preferred_name); assert.deepEqual(await page.locator('.conversation-filter input[type="date"]').evaluateAll(nodes => nodes.map(node => node.value)), ['', ''], 'all records clears earlier chat date filters'); await nav(page, '服务用量', width < 900); await usage(page); await deviceControl(page).selectOption(''); assert.match(await page.locator('.cp-usage').innerText(), /全部.*日期|不.*日期/); await page.getByRole('link', { name: '查看全部聊天记录 →', exact: true }).click(); await selected(page, subjects[1].preferred_name); // The household switch must discard the device even before the roster arrives. await nav(page, '服务用量', width < 900); await usage(page); await deviceControl(page).selectOption(devices[1].id); 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(); await usage(page); await page.waitForFunction(device => [...document.querySelectorAll('.cp-usage select option')].some(o => o.value === device), id(520)); assert.equal(await deviceControl(page).inputValue(), ''); await page.getByRole('link', { name: '查看全部聊天记录 →', exact: true }).click(); await selected(page, roster[2].preferred_name); await page.locator('.cp-memory-row').nth(1).waitFor(); assert.equal(await page.locator('.cp-memory-row').count(), 2); await page.goBack(); await usage(page); assert.equal(await deviceControl(page).inputValue(), ''); assert.deepEqual(unexpected, []); checks.push(`${width}px: device owner, existing elder, all devices, household switch, Back`); } catch (error) { await page.screenshot({ path: resolve(output, `failure-${width}.png`) }); await writeFile(resolve(output, `failure-${width}.txt`), await page.locator('body').innerText()); throw error; } finally { await context.close(); } } for (const params of [ `household_id=${familyA.id}&subject_id=${id(410)}&usage_device_id=${id(520)}`, `household_id=${familyB.id}&subject_id=${subjects[1].id}&usage_device_id=${devices[1].id}`, `household_id=${id(999)}&subject_id=${id(999)}&usage_device_id=${id(999)}`, `household_id=${familyA.id}&usage_device_id=${id(530)}`, `household_id=${familyA.id}&subject_id=${subjects[0].id}&usage_device_id=${devices[1].id}`, ]) { const { page, context, unexpected } = await start({ width: 1188, height: 761 }, '?section=conversations&' + params); const expected = params.includes(`household_id=${familyB.id}`) ? roster[2] : params.includes(`usage_device_id=${devices[1].id}`) ? subjects[1] : subjects[0]; await selected(page, expected.preferred_name); assert.deepEqual(unexpected, []); await context.close(); checks.push(`Validated direct route: ${params}`); } assert(reads.filter(r => r.kind === 'roster').every(r => households.some(h => h.id === r.householdID))); assert(reads.filter(r => r.kind === 'calls').every(r => roster.some(s => s.id === r.elder_id))); assert.deepEqual(mutations, []); assert.deepEqual(external, []); assert.deepEqual(textRequests, []); assert.deepEqual(errors, []); const result = { passed: true, checks, reads, mutations, external, textRequests, browserErrors: errors }; await writeFile(resolve(output, 'checks.json'), JSON.stringify(result, null, 2)); console.log(JSON.stringify({ ...result, reads: reads.length })); } catch (error) { await writeFile(resolve(output, 'checks.json'), JSON.stringify({ passed: false, error: error.message, checks, reads, mutations, external, textRequests, browserErrors: errors }, null, 2)); throw error; } finally { await browser?.close(); await server.close(); assert(resolve(work).startsWith(resolve(tmpdir()) + (process.platform === 'win32' ? '\\' : '/'))); await rm(work, { recursive: true, force: true }); }