Files
story/test/e2e/family-device-guidance.mjs
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

165 lines
12 KiB
JavaScript

// 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('<!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/__guidance.tsx"></script>');
}); },
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 });
}