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('
'); }); }, 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 }); }