Files
story/test/e2e/device-firmware-entry.mjs
T

196 lines
15 KiB
JavaScript
Raw Normal View History

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