Files
story/test/e2e/usage-chat-context.mjs
T

151 lines
14 KiB
JavaScript
Raw Normal View History

// 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 }); }