Files
story/test/e2e/usage-chat-context.mjs
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

151 lines
14 KiB
JavaScript

// 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('<!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-context.tsx"></script>'); }); },
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 }); }