Files
story/test/e2e/family-device-guidance.mjs
T

165 lines
12 KiB
JavaScript
Raw Normal View History

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