Files
story/test/e2e/device-firmware-entry.mjs
T
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

196 lines
15 KiB
JavaScript

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 cacheDir = await mkdtemp(join(tmpdir(), 'device-firmware-vite-'));
const server = await createServer({
configFile: false, root: resolve('web'), cacheDir, oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error',
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: 5330, strictPort: true },
plugins: [{
name: 'device-firmware-entry-test',
configureServer(server) {
server.middlewares.use('/__device-firmware-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/__device-firmware-module.tsx"></script>');
});
},
resolveId(id) { if (id === '__device-firmware-module.tsx') return '\0__device-firmware-module.tsx'; },
load(id) {
if (id !== '\0__device-firmware-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 '/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);
window.fetch=(input,init)=>{const url=new URL(typeof input==='string'?input:input instanceof URL?input.href:input.url,location.origin);
if(url.pathname==='/api/v1/events')return Promise.resolve(new Response(new ReadableStream({start(controller){controller.enqueue(new TextEncoder().encode(': test\\n\\n'));init?.signal?.addEventListener('abort',()=>{try{controller.close()}catch{}})}}),{headers:{'Content-Type':'text/event-stream'}}));
return nativeFetch(input,init);};
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 = [];
let page;
const requests = [];
try {
page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
page.setDefaultTimeout(15000);
await page.clock.install();
page.on('pageerror', error => problems.push(error.message));
const householdID = '10000000-0000-4000-8000-000000000211', deviceID = '10000000-0000-4000-8000-000000000511', subjectID = '10000000-0000-4000-8000-000000000411';
const available = () => ({ online: true, current_version: '1.7.0-controls.22', current_version_code: 6, ota_supported: true, update_available: true, latest: { id: 'test-release', model: 'ECHOEAR-S3', version: '1.7.0-controls.23', version_code: 7, release_notes: '在屏幕上显示通话状态。' } });
let firmware = available(), firmwareFails = false, historyFails = false, denyOTA = false, canManage = true;
let firmwareReads = 0, commandReads = 0, holdNext = false, heldResolve, releaseHeld;
const mutations = [];
const held = new Promise(resolve => { heldResolve = resolve; });
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname, method = route.request().method();
requests.push(`${method} ${path}`);
const capabilities = ['household.read', 'household.manage', 'subject.read', 'subject.write', 'conversation.read', 'privacy.read', 'story.review', ...(canManage ? ['device.manage'] : [])];
const answer = data => route.fulfill({ json: { ok: true, data } });
const fail = message => route.fulfill({ status: 503, json: { ok: false, code: 'SYNTHETIC_FAILURE', message } });
if (path.endsWith('/account/session')) return answer({ authenticated: true, csrf_token: 'test-only' });
if (path === '/api/v1/me') return answer({ identity: { issuer: 'test-only', subject: 'owner', display_name: '家人' }, accounts: [{ user_id: 'owner', tenant_id: 'tenant', tenant_name: '安心陪伴', platform_role: 'customer', account_kind: 'customer', household_memberships: [{ household_id: householdID, household_name: '我们的家', role: canManage ? 'owner' : 'viewer', capabilities }] }] });
if (path.endsWith('/households')) return answer({ items: [{ id: householdID, name: '我们的家', status: 'active', role: canManage ? 'owner' : 'viewer', capabilities }] });
if (path.endsWith('/subjects')) return answer({ items: [{ id: subjectID, household_id: householdID, preferred_name: '奶奶', timezone: 'Asia/Shanghai', language: 'zh-CN', status: 'active', profile_data: {}, conversation_preferences: {} }] });
if (path.endsWith('/devices')) return answer({ items: [{ id: deviceID, household_id: householdID, subject_id: subjectID, subject_name: '奶奶', model: 'ECHOEAR-S3', status: 'active', desired_revision: 1, reported_revision: 1 }] });
if (path.endsWith('/presence')) return answer({ device_id: deviceID, online: true, status: 'online', source: 'mqtt' });
if (path.endsWith('/twin')) return answer({ device_id: deviceID, desired_config: { volume: { speaker: 60 } }, reported_config: { volume: { speaker: 60 } }, desired_revision: 1, reported_revision: 1, sync_state: 'applied', capabilities: {} });
if (path.endsWith('/readiness') || path.endsWith('/conversation-readiness')) return answer({ subject_id: subjectID, ready: true, checks: [], devices: [], start_time_checks: [] });
if (path.endsWith('/firmware')) {
firmwareReads += 1;
const data = structuredClone(firmware);
if (holdNext) { holdNext = false; heldResolve(); await new Promise(resolve => { releaseHeld = resolve; }); }
return firmwareFails ? fail('固件服务暂时无法读取') : answer(data);
}
if (path.endsWith('/commands') && method === 'GET') { commandReads += 1; return historyFails ? fail('命令历史暂时无法读取') : answer({ items: [] }); }
if (path.endsWith('/ota') && method === 'POST') {
mutations.push({ path, body: route.request().postDataJSON() });
if (denyOTA) return fail('设备正在通话,请稍后重试。');
firmware.ota_task = { task_id: 'test-ota', status: 'pending', version: firmware.latest.version, version_code: firmware.latest.version_code };
return answer(firmware.ota_task);
}
if (method !== 'GET') throw new Error(`unexpected test mutation ${method} ${path}`);
return answer({ items: [] });
});
await page.goto('http://127.0.0.1:5330/__device-firmware-test?section=devices', { waitUntil: 'domcontentloaded' });
const panel = page.getByRole('region', { name: '固件升级', exact: true });
const update = () => panel.getByRole('button', { name: '更新固件', exact: true });
const refresh = () => panel.getByRole('button', { name: '检查更新', exact: true }).click();
const confirm = () => panel.getByRole('group', { name: '确认设备操作' }).getByRole('button', { name: '确认', exact: true }).click();
await panel.getByText('1.7.0-controls.23', { exact: true }).waitFor();
await server.waitForRequestsIdle();
assert.equal(firmwareReads, 1);
assert.equal(commandReads, 1);
assert(await update().isEnabled());
await page.getByRole('button', { name: '固件升级', exact: true }).click();
assert(await panel.evaluate(element => element === document.activeElement));
const cardLayout = await page.evaluate(() => ({ firmware: document.querySelector('.device-controls').getBoundingClientRect().top, settings: [...document.querySelectorAll('.cp-setting-row')].find(element => element.textContent.includes('音量与设置')).getBoundingClientRect().top }));
assert(cardLayout.firmware < cardLayout.settings);
await page.getByRole('button', { name: '查看设备详情', exact: true }).click();
await page.locator('.cp-device-detail').getByRole('button', { name: /固件升级/ }).click();
assert.equal(await page.locator('.cp-device-detail').count(), 0);
assert(await panel.evaluate(element => element === document.activeElement));
assert.equal(firmwareReads, 1, 'card and detail must reuse one firmware component');
checks.push('actual CustomerPortal device card and detail expose firmware upgrade and reuse one authoritative GET');
historyFails = true; await refresh();
await panel.getByText('1.7.0-controls.23', { exact: true }).waitFor();
await page.waitForFunction(() => document.querySelector('.device-controls button')?.textContent === '检查更新');
assert(await update().isEnabled());
await panel.getByText('远程控制与最近操作', { exact: true }).click();
await panel.getByRole('alert').filter({ hasText: '最近操作读取失败' }).waitFor();
checks.push('command-history failure does not hide firmware status or disable an available update');
firmwareFails = true; await refresh();
await panel.getByRole('alert').filter({ hasText: '固件服务暂时无法读取' }).waitFor();
assert.equal(await panel.getByText('1.7.0-controls.23', { exact: true }).count(), 0);
assert.equal(await panel.getByText('暂无可更新的版本。', { exact: true }).count(), 0);
assert(await update().isDisabled());
firmwareFails = false; historyFails = false; await refresh();
await panel.getByText('1.7.0-controls.23', { exact: true }).waitFor();
checks.push('firmware read failure clears stale versions, disables mutation, and recovers without claiming latest');
firmware.online = false; await refresh();
await panel.getByText('设备离线,联网后可更新或重启。', { exact: true }).waitFor();
assert(await update().isDisabled());
firmware.online = true; firmware.ota_supported = false; await refresh();
await panel.getByText('当前固件尚未支持在线升级。', { exact: true }).waitFor();
assert(await update().isDisabled());
firmware = available(); await refresh();
await panel.getByText('有新版本可更新。升级时请保持通电,通话结束后再操作。', { exact: true }).waitFor();
checks.push('offline and unsupported states remain accurate and block upgrade');
await update().click();
firmware.latest = { ...firmware.latest, version: '1.7.0-controls.24', version_code: 8 };
await refresh();
await panel.getByText('1.7.0-controls.24', { exact: true }).waitFor();
await confirm();
await panel.getByText('可升级版本或设备状态已变化,请重新检查并选择升级。', { exact: true }).waitFor();
assert.deepEqual(mutations, []);
firmware = available(); await refresh();
await panel.getByText('1.7.0-controls.23', { exact: true }).waitFor();
denyOTA = true; await update().click(); await confirm();
await panel.getByText('设备正在通话,请稍后重试。', { exact: true }).waitFor();
assert.equal(mutations.length, 1);
assert.equal(await panel.getByText('升级请求已发送,请保持设备通电和联网。', { exact: true }).count(), 0);
denyOTA = false; await confirm();
await panel.getByText('升级请求已发送,请保持设备通电和联网。', { exact: true }).waitFor();
await panel.getByText('升级状态:等待设备接收', { exact: true }).waitFor();
assert.deepEqual(mutations.map(item => item.body), [{ version_code: 7 }, { version_code: 7 }]);
assert(mutations.every(item => item.path === `/api/v1/devices/${deviceID}/ota`));
checks.push('confirmation locks version_code, blocks changed release, reports backend busy, and posts only the selected real OTA contract');
firmware.ota_task.status = 'accepted'; await page.clock.runFor(10050);
await panel.getByText('升级状态:设备正在升级', { exact: true }).waitFor();
assert(await panel.getByRole('button', { name: '升级进行中', exact: true }).isDisabled());
firmware.current_version = firmware.latest.version; firmware.current_version_code = 7; firmware.update_available = false; firmware.ota_task.status = 'success';
await page.clock.runFor(10050);
await panel.getByText('升级状态:升级完成', { exact: true }).waitFor();
assert.equal(await panel.getByText('1.7.0-controls.23', { exact: true }).count(), 2);
assert(await update().isDisabled());
checks.push('10-second polling follows accepted/success and reported current version without optimistic completion');
holdNext = true; const beforeHold = firmwareReads;
await refresh(); await held;
assert(await panel.getByRole('button', { name: '正在检查…', exact: true }).isDisabled());
await page.clock.runFor(10050);
assert.equal(firmwareReads, beforeHold + 1);
releaseHeld(); await panel.getByRole('button', { name: '检查更新', exact: true }).waitFor();
checks.push('manual refresh and periodic poll cannot overlap');
const evidence = process.env.DEVICE_FIRMWARE_OUTPUT_DIR;
if (evidence) { await mkdir(evidence, { recursive: true }); await page.screenshot({ path: resolve(evidence, 'firmware-desktop.png'), fullPage: true }); }
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: '固件升级', exact: true }).click();
assert(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) < 3);
assert(await panel.getByRole('button', { name: '检查更新', exact: true }).isVisible());
if (evidence) await page.screenshot({ path: resolve(evidence, 'firmware-mobile.png'), fullPage: true });
checks.push('mobile upgrade entry and version facts are visible without horizontal overflow');
canManage = false; const beforeViewerCommands = commandReads;
await page.reload({ waitUntil: 'domcontentloaded' });
await panel.getByText('此账号可查看版本,升级请联系家庭管理员。', { exact: true }).waitFor();
assert.equal(await update().count(), 0);
assert.equal(commandReads, beforeViewerCommands);
assert.equal(mutations.length, 2);
checks.push('viewer can read versions but has no update/reboot controls and sends no command-history request');
assert.deepEqual(problems, []);
console.log(JSON.stringify({ checks, problems }, null, 2));
} catch (error) {
console.error(JSON.stringify({ checks, problems, requests, body: await page?.locator('body').innerText() }, null, 2));
throw error;
} finally {
await browser.close(); await server.close();
if (dirname(cacheDir) !== resolve(tmpdir()) || !basename(cacheDir).startsWith('device-firmware-vite-')) throw new Error('unexpected test cache path');
await rm(cacheDir, { recursive: true, force: true });
}