feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

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.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+176
View File
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { pathToFileURL } from 'node:url';
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 elderA = '10000000-0000-4000-8000-000000000411';
const elderB = '10000000-0000-4000-8000-000000000412';
const cacheDir = await mkdtemp(join(tmpdir(), 'home-overview-vite-'));
const server = await createServer({
configFile: false, root: resolve('web'), cacheDir, oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error',
// The test's virtual entry is absent from index.html. Prebundle its dependencies
// without sharing the production checkout's optimizer cache or discovering new
// bundles during interactions, which can otherwise reload the test document.
optimizeDeps: { noDiscovery: true, include: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom', 'react-dom/client', 'antd', '@ant-design/icons', '@phosphor-icons/react', 'oidc-client-ts', 'go-captcha-react'] },
server: { host: '127.0.0.1', port: 5329, strictPort: true },
plugins: [{
name: 'home-overview-test',
configureServer(server) {
server.middlewares.use('/__home-test', (_req, res) => {
res.setHeader('Content-Type', 'text/html');
res.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/__home-module.tsx"></script>');
});
},
resolveId(id) { if (id === '__home-module.tsx') return '\0__home-module.tsx'; },
load(id) {
if (id !== '\0__home-module.tsx') return;
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 {installPortalFixtures} from '/src/dev/portal-fixtures.ts';
import '/src/styles.css';import '/src/cal-theme.css';import '/src/customer-responsive.css';import '/src/components/home/customer-portal.css';
const nativeFetch=window.fetch.bind(window);installPortalFixtures();const fixtureFetch=window.fetch.bind(window);
window.__homeRequests=[];
window.fetch=async(input,init)=>{const path=new URL(typeof input==='string'?input:input instanceof URL?input.href:input.url,location.origin).pathname;window.__homeRequests.push(path);
if(path.endsWith('/home-overview'))return nativeFetch(input,init);
const response=await fixtureFetch(input,init);
if(path.endsWith('/subjects')){const body=await response.json();body.data.items.push({...body.data.items[0],id:'${elderB}',preferred_name:'爷爷'});return new Response(JSON.stringify(body),{headers:{'Content-Type':'application/json'}})}
return response;};
createRoot(document.getElementById('root')).render(React.createElement(AuthProvider,null,React.createElement(CustomerPortal)));`;
},
}],
});
await server.listen();
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
const checks = [], problems = [];
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.clock.install();
let documentLoads = 0;
page.on('domcontentloaded', () => { documentLoads += 1; });
page.setDefaultTimeout(15000);
page.on('pageerror', error => problems.push(error.message));
const overview = (subjectID, summary, duration = 98000, count = 35) => ({
subject_id: subjectID, timezone: 'Asia/Shanghai', week_start: '2026-09-28', week_end: '2026-10-05',
conversation_count: count, total_duration_ms: count ? duration : 0,
daily: Array.from({ length: 7 }, (_, index) => ({ date: new Date(Date.UTC(2026, 8, 28 + index)).toISOString().slice(0, 10), conversation_count: index === 3 ? count : 0, duration_ms: index === 3 && count ? duration : 0 })),
latest_conversation: { id: `latest-${subjectID}`, source: 'call_memory', status: 'FAILED', started_at: '2026-10-01T14:00:11Z', ended_at: '2026-10-01T14:01:49Z', duration_ms: duration, summary, summary_state: 'READY', summary_kind: 'memory' },
});
let responseA = overview(elderA, '奶奶本次聊到阳台新开的花。');
let responseB = overview(elderB, '爷爷本次聊到下棋。', 12000, 1);
let failure = false, holdNext = false, heldResolve, releaseHeld;
let held = new Promise(resolve => { heldResolve = resolve; });
const requests = [];
await page.route('**/api/v1/subjects/*/home-overview', async route => {
const subjectID = route.request().url().split('/subjects/')[1].split('/')[0];
requests.push(subjectID);
const data = structuredClone(subjectID === elderA ? responseA : responseB);
if (holdNext && subjectID === elderA) {
holdNext = false; heldResolve();
await new Promise(resolve => { releaseHeld = resolve; });
}
await route.fulfill(failure ? { status: 500, json: { ok: false, code: 'TEST_FAILURE', message: '近况暂时读取失败' } } : { json: { ok: true, data } }).catch(() => {});
});
await page.goto('http://127.0.0.1:5329/__home-test', { waitUntil: 'domcontentloaded', timeout: 30000 });
const latest = page.locator('.ch-latest');
const metrics = page.locator('.ch-metrics strong');
const refresh = () => page.getByRole('button', { name: '刷新陪伴近况', exact: true }).click();
await latest.getByText('奶奶本次聊到阳台新开的花。', { exact: true }).waitFor();
await server.waitForRequestsIdle();
assert.deepEqual(await metrics.allTextContents(), ['35', '1.6']);
assert.match(await latest.innerText(), /聊天记忆摘要/);
assert.match(await latest.innerText(), /10月1日 22:00 · 1分38秒/);
assert.match(await latest.innerText(), /聊天中断/);
assert.equal(requests.length, 1);
const oldRequests = await page.evaluate(() => window.__homeRequests.filter(path => /\/subjects\/[^/]+\/conversations$|\/conversations\/[^/]+\/summaries$/.test(path)));
assert.deepEqual(oldRequests, []);
checks.push('actual CustomerPortal uses one overview request, no legacy list/summary requests, full aggregate and real FAILED latest chat');
await page.locator('.ch-sidebar').getByRole('link', { name: '聊天记录', exact: true }).click();
await page.clock.runFor(30050);
assert.equal(requests.length, 1);
responseA = overview(elderA, '返回首页后取到的新聊天。');
await page.locator('.ch-sidebar').getByRole('link', { name: '首页', exact: true }).click();
await latest.getByText('返回首页后取到的新聊天。', { exact: true }).waitFor();
assert.equal(requests.length, 2);
checks.push('leaving home stops polling; returning refreshes the overview independently of legacy history');
holdNext = true;
await refresh(); await held;
await page.evaluate(() => { window.dispatchEvent(new Event('focus')); document.dispatchEvent(new Event('visibilitychange')); });
assert.equal(requests.length, 3);
await page.getByRole('button', { name: '查看爷爷的陪伴近况', exact: true }).click();
assert.equal(await page.locator('.ch-sidebar').getByRole('link', { name: '首页', exact: true }).getAttribute('aria-current'), 'page');
await page.getByRole('heading', { name: '爷爷的陪伴近况', exact: true }).waitFor();
await latest.getByText('爷爷本次聊到下棋。', { exact: true }).waitFor();
assert.deepEqual(await metrics.allTextContents(), ['1', '<1']);
assert.equal(await page.getByText('返回首页后取到的新聊天。', { exact: true }).count(), 0);
releaseHeld();
await page.waitForTimeout(100);
assert.match(await latest.innerText(), /爷爷本次聊到下棋/);
assert.match(await latest.innerText(), /12秒/);
checks.push('overlapping refreshes are deduplicated; switching elder clears prior data and ignores late response; short duration remains visible');
failure = true;
await refresh();
await page.getByRole('alert').filter({ hasText: '暂时无法载入陪伴近况' }).waitFor();
assert.equal(await page.getByText('爷爷本次聊到下棋。', { exact: true }).count(), 0);
assert.equal(await metrics.count(), 0);
failure = false;
responseB.latest_conversation.summary = '恢复后同步的最新聊天。';
const beforeRetry = requests.length;
await page.getByRole('button', { name: '重新加载', exact: true }).click();
await latest.getByText('恢复后同步的最新聊天。', { exact: true }).waitFor();
assert.equal(requests.length, beforeRetry + 1);
checks.push('failed refresh removes stale metrics and summary; retry makes one request and restores latest content');
responseB = overview(elderB, '上周聊天仍是最近一通。', 12000, 0);
responseB.latest_conversation.started_at = '2026-09-27T08:00:00Z';
await refresh();
await latest.getByText('上周聊天仍是最近一通。', { exact: true }).waitFor();
assert.deepEqual(await metrics.allTextContents(), ['0', '0']);
checks.push('zero chats this week does not hide the latest chat from the previous week');
responseB.latest_conversation.summary_state = 'INSUFFICIENT_TRANSCRIPT';
await refresh();
await latest.getByText('本次聊天文字不足,未生成总结。', { exact: true }).waitFor();
responseB.latest_conversation.summary_state = 'PROCESSING';
await refresh();
await latest.getByText('聊天已结束,摘要正在整理中。', { exact: true }).waitFor();
checks.push('insufficient text and processing have distinct accurate status text');
const longSummary = '定时刷新后的新聊天。爷爷聊起年轻时住过的院子、家门口的老树,以及和邻居一起下棋的往事。他说这些温暖的日常一直记在心里,最近也想和家人去公园走走,看看秋天的桂花,再聊一聊过去做过的拿手菜。';
responseB = overview(elderB, longSummary, 98000, 35);
const beforePoll = requests.length;
await page.clock.runFor(30050);
await latest.getByText(longSummary, { exact: true }).waitFor();
assert.equal(requests.length, beforePoll + 1);
checks.push('visible homepage refreshes every 30 seconds');
const evidence = process.env.HOME_OVERVIEW_OUTPUT_DIR;
if (evidence) { await mkdir(evidence, { recursive: true }); await page.screenshot({ path: resolve(evidence, 'home-desktop.png'), fullPage: true }); }
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: width === 390 ? 844 : 1000 });
assert(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) < 3);
assert(await page.locator('.ch-latest-summary').evaluate(element => element.clientHeight >= parseFloat(getComputedStyle(element).lineHeight) * 2 - 1), 'long summary must exercise two visible lines');
const boxes = await page.locator('.ch-weekly, .ch-latest').evaluateAll(panels => panels.map(panel => ({ panel: panel.getBoundingClientRect().bottom, content: Math.max(...Array.from(panel.querySelectorAll('*')).map(child => child.getBoundingClientRect().bottom)) })));
assert(boxes.every(box => box.content <= box.panel + 1), `home card content exceeds panel at ${width}px: ${JSON.stringify(boxes)}`);
}
if (evidence) await page.screenshot({ path: resolve(evidence, 'home-mobile.png'), fullPage: true });
checks.push('desktop and mobile show two-line memory summary plus interruption label without horizontal or card content overflow');
assert.equal(documentLoads, 1, 'the test document must not reload during interactions');
assert.deepEqual(problems, []);
console.log(JSON.stringify({ checks, problems }, null, 2));
} catch (error) {
console.error(JSON.stringify({ checks, problems }, null, 2));
throw error;
} finally {
await browser.close();
await server.close();
if (dirname(cacheDir) !== resolve(tmpdir()) || !basename(cacheDir).startsWith('home-overview-vite-')) throw new Error('unexpected test cache path');
await rm(cacheDir, { recursive: true, force: true });
}