// Real portal routing and dialogs with synthetic account data. All writes and // external requests are blocked; supported Bluetooth is a non-operating stub. 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, 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-family-device-guidance-')); const output = resolve(process.env.UI_GUIDANCE_OUTPUT ?? '../.local-evidence/family-device-guidance-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: 'family-device-guidance-fixture', configureServer(vite) { vite.middlewares.use('/__guidance', (_q, response) => { response.setHeader('Content-Type', 'text/html'); response.end('
'); }); }, resolveId(name) { if (name === '__guidance.tsx') return '\0__guidance.tsx'; }, load(name) { if (name === '\0__guidance.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 errors = [], externalRequests = [], mutations = [], checks = [], failures = []; let browser, origin; async function run(name, options, test) { const context = await browser.newContext({ viewport: options.viewport, serviceWorkers: 'block' }); const unexpected = await installCustomerFixture(context); await context.addInitScript(supported => { window.bluetoothRequests = 0; Object.defineProperty(navigator, 'bluetooth', { configurable: true, value: supported ? { requestDevice() { window.bluetoothRequests++; throw new Error('Synthetic Bluetooth must not be invoked'); }, } : undefined }); }, !!options.bluetooth); const page = await context.newPage(); page.setDefaultTimeout(15000); 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) { externalRequests.push(url.origin + path); return route.abort(); } if (!path.startsWith('/api/')) return route.continue(); if (request.method() !== 'GET') { mutations.push(`${request.method()} ${path}`); return route.abort(); } const answer = data => route.fulfill({ json: { ok: true, data, request_id: 'synthetic-guidance' } }); if (path.endsWith('/events/history') || path.endsWith('/firmware')) return answer({ items: [] }); if (path.endsWith('/home-overview')) return answer({ subject_id: id(400), household_id: id(200), timezone: 'Asia/Shanghai', week_start: '2026-10-05', week_end: '2026-10-12', daily: Array.from({ length: 7 }, (_, index) => ({ date: '2026-10-' + String(index + 5).padStart(2, '0'), conversation_count: 0, duration_ms: 0 })), conversation_count: 0, total_duration_ms: 0, latest_conversation: null }); if (path === '/api/v1/devices') return answer({ items: options.owned ? [{ ...devices[0], account_binding_id: id(991), household_id: undefined, subject_id: undefined, subject_name: '' }] : [] }); if (path.endsWith('/conversation-readiness')) return answer({ subject_id: id(400), ready: false, devices: [], evaluated_at: new Date().toISOString(), start_time_checks: [], checks: [ { code: 'DEVICE_BOUND', status: 'blocked', blocking: true, title: '尚未绑定设备', detail: '请使用设备屏幕显示的一次性绑定码完成绑定。', action: 'BIND_DEVICE' }, ] }); return route.fallback(); }); try { await page.goto(`${origin}/__guidance${options.section ? '?section=' + options.section : ''}`); await test(page); assert.deepEqual(unexpected, []); assert.equal(await page.evaluate(() => window.bluetoothRequests), 0); checks.push(name); } catch (error) { failures.push({ name, message: error.message }); await page.screenshot({ path: resolve(output, `${name}-failure.png`) }); } finally { await context.close(); } } 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 viewport of [{ width: 1188, height: 761 }, { width: 389, height: 844 }]) { await run(`rename-${viewport.width}`, { viewport, section: 'members' }, async page => { const opener = page.getByRole('button', { name: /编辑名称/ }); await opener.click(); const dialog = page.locator('dialog.household-name-dialog'); await dialog.waitFor(); const input = dialog.getByRole('textbox'), original = await input.inputValue(); await input.fill('未保存的合成家庭草稿'); const cancel = dialog.getByRole('button', { name: '取消', exact: true }); const save = dialog.getByRole('button', { name: '保存', exact: true }); const close = dialog.getByRole('button', { name: '关闭编辑家庭', exact: true }); assert(await save.isEnabled()); const metrics = await dialog.evaluate(element => { const rect = node => { const r = node.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right }; }; const footer = element.querySelector('footer'), buttons = [...footer.querySelectorAll('button')]; return { dialog: rect(element), header: rect(element.querySelector('header')), title: rect(element.querySelector('h3')), close: rect(element.querySelector('header button')), gap: rect(buttons[1]).x - rect(buttons[0]).right, footerDisplay: getComputedStyle(footer).display, buttons: buttons.map(node => ({ ...rect(node), background: getComputedStyle(node).backgroundColor, padding: getComputedStyle(node).padding })) }; }); await writeFile(resolve(output, `rename-${viewport.width}-metrics.json`), JSON.stringify(metrics, null, 2)); await page.screenshot({ path: resolve(output, `rename-${viewport.width}.png`) }); assert(metrics.gap >= 10, 'cancel and save need visible spacing'); assert(metrics.buttons.every(button => button.width >= 44 && button.height >= 44), 'actions need touch targets'); assert(metrics.close.width >= 44 && metrics.close.height >= 44, 'close needs a touch target'); assert(metrics.close.x > metrics.title.right, 'close must be separate from the title'); assert.notEqual(metrics.buttons[0].background, metrics.buttons[1].background, 'primary and secondary actions differ'); assert.notEqual(metrics.buttons[1].background, 'rgba(0, 0, 0, 0)'); assert(metrics.dialog.x >= 0 && metrics.dialog.right <= viewport.width + 1); await save.focus(); await page.keyboard.press('Tab'); assert(await close.evaluate(el => el === document.activeElement)); await page.keyboard.press('Shift+Tab'); assert(await save.evaluate(el => el === document.activeElement)); await cancel.click(); await dialog.waitFor({ state: 'detached' }); assert(await opener.evaluate(el => el === document.activeElement)); await opener.click(); await dialog.waitFor(); assert.equal(await input.inputValue(), original); await input.fill('另一份未保存草稿'); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' }); assert(await opener.evaluate(el => el === document.activeElement)); assert.deepEqual(mutations, []); }); for (const bluetooth of [false, true]) await run(`add-${viewport.width}-${bluetooth}`, { viewport, bluetooth }, async page => { const preparation = page.locator('.ch-preparation'); await preparation.getByRole('button', { name: '绑定陪伴设备', exact: true }).waitFor(); assert.doesNotMatch(await preparation.innerText(), /一次性绑定码/); assert.match(await preparation.innerText(), /蓝牙/); const action = preparation.getByRole('button', { name: '绑定陪伴设备', exact: true }); await action.focus(); await page.keyboard.press('Enter'); await page.waitForURL(url => url.searchParams.get('section') === 'devices'); await page.getByRole('heading', { name: '陪伴设备', exact: true }).waitFor(); const dialog = page.getByRole('dialog', { name: '添加陪伴设备', exact: true }); assert.equal(await dialog.count(), 1, 'home bind action opens the existing add flow directly'); await dialog.waitFor(); assert.equal(await dialog.getByRole('list', { name: '添加步骤' }).locator('li').count(), 3); if (bluetooth) assert(await dialog.getByRole('button', { name: '搜索附近设备', exact: true }).isEnabled()); else { const notice = dialog.getByRole('alert'); assert.match(await notice.innerText(), /HTTPS/); assert.match(await notice.innerText(), /同一账号/); assert.match(await notice.innerText(), /添加设备/); assert.match(await notice.innerText(), /Chrome/); assert.doesNotMatch(await notice.innerText(), /小程序|绑定码/); assert.equal(await dialog.getByRole('button', { name: '搜索附近设备', exact: true }).count(), 0); assert.equal(await dialog.getByRole('status').count(), 0, 'unsupported browsers do not ask the user to search here'); } await page.screenshot({ path: resolve(output, `add-${viewport.width}-${bluetooth}.png`) }); assert(await dialog.evaluate(el => el.contains(document.activeElement))); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' }); await page.goBack(); await preparation.waitFor(); await preparation.locator('summary').click(); await preparation.getByRole('button', { name: /前往处理/ }).click(); await dialog.waitFor(); await page.goBack(); await preparation.waitFor(); if (viewport.width < 900) await page.getByRole('button', { name: '打开导航菜单', exact: true }).click(); await page.getByRole('link', { name: '陪伴设备', exact: true }).click(); await page.waitForURL(url => url.searchParams.get('section') === 'devices'); assert.equal(await dialog.count(), 0, 'ordinary device navigation does not auto-open setup'); }); await run(`existing-${viewport.width}`, { viewport, owned: true }, async page => { await page.locator('.ch-preparation').getByRole('button', { name: '绑定陪伴设备', exact: true }).click(); await page.waitForURL(url => url.searchParams.get('section') === 'devices'); await page.getByRole('button', { name: '绑定老人', exact: true }).waitFor(); assert.equal(await page.getByRole('dialog', { name: '添加陪伴设备', exact: true }).count(), 0, 'an already added device stays available for assignment'); await page.getByRole('button', { name: /^(更换 Wi-Fi|重新连接)$/ }).click(); const notice = page.getByRole('dialog').getByRole('alert'); assert.match(await notice.innerText(), /找到该设备并继续当前操作/); assert.doesNotMatch(await notice.innerText(), /添加设备/); await page.keyboard.press('Escape'); }); } assert.deepEqual(errors, []); assert.deepEqual(externalRequests, []); assert.deepEqual(mutations, []); const result = { passed: !failures.length, checks, failures, browserErrors: errors, externalRequests, mutations }; await writeFile(resolve(output, 'checks.json'), JSON.stringify(result, null, 2)); console.log(JSON.stringify(result)); assert.deepEqual(failures, []); } finally { await browser?.close(); await server.close(); assert(resolve(work).startsWith(resolve(tmpdir()) + (process.platform === 'win32' ? '\\' : '/'))); await rm(work, { recursive: true, force: true }); }