165 lines
12 KiB
JavaScript
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 });
|
||
|
|
}
|