Files
story/test/e2e/dot-report-regression.mjs
T
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

181 lines
15 KiB
JavaScript

import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
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 output = resolve(process.env.QA_OUTPUT ?? '.tmp-dot-report/evidence');
await mkdir(output, { recursive: true });
const server = await createServer({ configFile: false, root: resolve('web'), oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 5327, strictPort: true }, plugins: [{
name: 'dot-report-regression',
configureServer(server) { server.middlewares.use('/__dot-test', (req, res) => { res.setHeader('Content-Type', 'text/html'); res.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/__dot-module.tsx"></script>'); }); },
resolveId(id) { if (id === '__dot-module.tsx') return '\0__dot-module.tsx'; },
load(id) { if (id === '\0__dot-module.tsx') return `
import React from 'react'; import {createRoot} from 'react-dom/client';
import {SaaSWorkspace} from '/src/pages/SaaSWorkspace.tsx';
import '/src/styles.css'; import '/src/cal-theme.css'; import '/src/customer-responsive.css'; import '/src/components/home/customer-portal.css';
const accounts=[{tenant_id:'qa-tenant',user_id:'qa-user',account_kind:'customer',tenant_name:'合成测试',display_name:'QA',policy_version:'local-v1',household_memberships:[]}];
function App(){const [section,setSection]=React.useState(new URL(location.href).searchParams.get('section')||'members');
return React.createElement(SaaSWorkspace,{accounts,accessToken:'synthetic',section,onSectionChange:setSection,redesignedHome:true,showNavigation:false});
}createRoot(document.getElementById('root')).render(React.createElement(App));`; }
}] });
await server.listen();
const browser = await chromium.launch({ headless: true, ...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}) });
const checks = [], problems = [], requests = [];
const capabilities = ['household.read', 'household.manage', 'member.manage', 'ownership.transfer', 'subject.read', 'subject.write', 'privacy.read', 'privacy.manage', 'conversation.read', 'story.review'];
const household = (id, name) => ({ id, name, tenant_id: 'qa-tenant', status: 'active', role: 'owner', capabilities, version: 1 });
let families = [household('B', 'QA家庭B'), household('A', 'QA家庭A')];
const subject = { id: 'qa-elder', household_id: 'A', preferred_name: 'QA测试长者', language: 'zh-CN', timezone: 'Asia/Shanghai', profile_data: { background: '合成测试资料', interests: ['养花', '散步', '戏曲', '做饭', '读书', '电影', '音乐', '园艺'] }, conversation_preferences: { pace: 'slow', conversation_goal: '日常陪伴', preferred_topics: [], avoided_topics: [] }, status: 'active', version: 1 };
const now = new Date().toISOString();
let page;
try {
page = await browser.newPage({ viewport: { width: 1165, height: 747 } });
page.setDefaultTimeout(10000);
page.on('pageerror', error => problems.push(error.message));
await page.route('**/api/v1/**', async route => {
const request = route.request(), path = new URL(request.url()).pathname, method = request.method();
const body = request.postData() ? request.postDataJSON() : undefined;
requests.push({ path, method, body });
let data = { items: [] };
if (path === '/api/v1/households') data = { items: families };
if (path.endsWith('/households/A/subjects')) data = { items: [subject] };
if (path.endsWith('/members')) data = { items: [{ id: 'qa-user', user_id: 'qa-user', display_name: 'QA', role: 'owner', status: 'active', is_owner: true, version: 1, joined_at: now }] };
if (path.endsWith('/events/history')) data = { items: ['SUBMITTED', 'VERIFYING', 'EXECUTING'].map((status, index) => ({ id: index + 1, type: 'privacy_request.updated', household_id: 'A', subject_id: subject.id, resource_type: 'privacy_request', resource_id: 'qa-request', status, occurred_at: now })) };
if (path === '/api/v1/events') return route.fulfill({ contentType: 'text/event-stream', body: ': synthetic test\n\n' });
if (path.endsWith('/readiness') || path.endsWith('/conversation-readiness')) data = { subject_id: subject.id, ready: false, checks: [], devices: [], start_time_checks: [] };
if (path.endsWith('/memories/calls/query')) data = { items: [], next_cursor: '' };
if (method === 'POST' && path === '/api/v1/households') { const created = household('C', body.name); families = [created, ...families]; data = created; }
if (method === 'PATCH' && /\/households\/[^/]+$/.test(path)) { families = families.map(item => item.id === path.split('/').at(-1) ? { ...item, name: body.name, version: item.version + 1 } : item); data = families.find(item => item.id === path.split('/').at(-1)); }
return route.fulfill({ json: { ok: true, data, request_id: 'local-qa' }, headers: { ETag: '"v1"' } });
});
const open = async section => { await page.goto(`http://127.0.0.1:5327/__dot-test?section=${section}`); await page.locator('.ch-sidebar .ch-household').waitFor({ state: 'attached' }); };
const selectFamily = async name => { await page.getByRole('button', { name: '切换家庭', exact: true }).click(); await page.getByRole('menuitem', { name, exact: true }).click(); await page.getByRole('button', { name: '切换家庭', exact: true }).filter({ hasText: name }).waitFor(); };
await open('members'); await selectFamily('QA家庭A');
await page.getByRole('button', { name: '编辑名称' }).waitFor();
await page.reload();
await page.getByRole('button', { name: '切换家庭', exact: true }).filter({ hasText: 'QA家庭A' }).waitFor();
await page.reload();
await page.getByRole('button', { name: '切换家庭', exact: true }).filter({ hasText: 'QA家庭A' }).waitFor();
checks.push('QA01: A remains selected after two reloads despite B being first');
for (const close of ['cancel', 'X', 'backdrop']) {
await page.getByRole('button', { name: '编辑名称' }).click();
const dialog = page.getByRole('dialog', { name: '编辑家庭' });
await dialog.locator('input').fill('已放弃的名称');
if (close === 'cancel') await dialog.getByRole('button', { name: '取消', exact: true }).click();
if (close === 'X') await dialog.getByRole('button', { name: '关闭', exact: true }).click();
if (close === 'backdrop') await page.locator('.workspace-dialog-backdrop').click({ position: { x: 5, y: 5 } });
await page.getByRole('button', { name: '编辑名称' }).click();
assert.equal(await dialog.locator('input').inputValue(), 'QA家庭A');
assert.equal(await dialog.getByRole('button', { name: '保存', exact: true }).isDisabled(), true);
await dialog.getByRole('button', { name: '取消', exact: true }).click();
}
checks.push('QA03: cancel, X and backdrop all discard the family-name draft');
await open('privacy');
await page.locator('.cp-consent-row').filter({ hasText: '个性化长期记忆' }).getByRole('button', { name: '管理', exact: true }).click();
const operations = page.locator('.subject-management-operations[open]');
await operations.waitFor();
assert.equal(await operations.locator('select').last().inputValue(), 'personalized_memory');
assert.match(await operations.innerText(), /故事整理(尚未授权)/);
assert.match(await operations.innerText(), /字幕保存(尚未授权)/);
assert.doesNotMatch(await operations.innerText(), /只有两项授权同时有效/);
const bounds = await operations.evaluate(node => ({ client: node.clientWidth, scroll: node.scrollWidth, fields: [...node.querySelectorAll('.tool-form input:not([type=checkbox]),.tool-form select,.tool-form button')].map(item => { const r = item.getBoundingClientRect(); return { width: r.width, left: r.left, right: r.right }; }) }));
assert.ok(bounds.scroll <= bounds.client + 1, JSON.stringify(bounds));
assert.ok(bounds.fields.every(field => field.left >= 0 && field.right <= 1165 && field.width >= 20), JSON.stringify(bounds));
await page.screenshot({ path: resolve(output, 'authorization-1165x747.png') });
checks.push('QA02/QA04: memory entry selects memory, explains independent dependency; no desktop overflow');
await operations.getByRole('button', { name: /查看隐私与授权说明/ }).click();
const disclosure = page.getByRole('dialog', { name: '隐私与授权说明' });
await disclosure.waitFor();
assert.match(await disclosure.innerText(), /local-v1/);
assert.match(await disclosure.innerText(), /撤回授权与删除已有数据是两个独立操作/);
assert.equal(requests.filter(request => request.method === 'POST' && request.path.endsWith('/consents')).length, 0);
await disclosure.getByRole('button', { name: '返回授权表单' }).click();
await operations.getByRole('button', { name: '关闭资料与授权' }).click();
await page.setViewportSize({ width: 390, height: 844 });
await open('privacy');
await page.locator('.cp-consent-row').filter({ hasText: '个性化长期记忆' }).getByRole('button', { name: '管理', exact: true }).click();
await operations.waitFor();
assert.ok(await operations.evaluate(node => node.scrollWidth <= node.clientWidth + 1));
await page.screenshot({ path: resolve(output, 'authorization-390x844.png') });
await operations.getByRole('button', { name: '关闭资料与授权' }).click();
checks.push('QA04: mobile authorization fields also fit without horizontal scrolling');
await page.setViewportSize({ width: 1165, height: 747 });
await open('subjects');
await page.locator('.cp-identity button').click();
await page.getByText('已达到 8 项上限,请先删除一项再添加。', { exact: true }).waitFor();
assert.equal(await page.locator('.subject-tag-editor__chips > span').count(), 8);
assert.equal(await page.locator('.subject-tag-editor__input input').isDisabled(), true);
checks.push('QA05: eighth interest shows the limit and prevents a ninth');
await page.getByRole('button', { name: '基本资料', exact: true }).click();
const timezone = page.locator('input[placeholder="例如 Asia/Shanghai"]');
await timezone.fill('Invalid/Zone');
await page.getByText('无效的时区,请填写有效时区,例如 Asia/Shanghai。', { exact: true }).waitFor();
assert.equal(await page.getByRole('button', { name: '保存资料', exact: true }).isDisabled(), true);
await timezone.fill('Europe/London');
await page.getByRole('button', { name: '保存资料', exact: true }).click();
await page.locator('#family-controlled-operation').waitFor();
assert.match(await page.locator('#family-controlled-operation').innerText(), /时区“Asia\/Shanghai”→“Europe\/London”/);
checks.push('QA06: invalid timezone blocked inline; valid change included in confirmation');
await open('subjects');
await page.getByRole('button', { name: /添加长者/ }).click();
const create = page.getByRole('dialog', { name: '新增长者', exact: true });
await create.locator('input[placeholder*="最多8项"]').first().fill('1、2、3、4、5、6、7、8、9');
await create.getByText('最多填写 8 项,请删除多余的内容后再保存。', { exact: true }).waitFor();
await create.locator('input[placeholder*="称呼"]').fill('超限测试');
await create.locator('textarea').fill('虚构背景');
await create.locator('input[placeholder*="工作经历"]').fill('合成测试陪伴');
await create.locator('button[type=submit]').click();
assert.equal(await create.isVisible(), true);
assert.equal(requests.filter(request => request.method === 'POST' && /\/households\/[^/]+\/subjects$/.test(request.path)).length, 0);
checks.push('QA05: comma-separated ninth interest is preserved and explicitly rejected, never truncated');
await open('conversations');
await page.getByText('查看早期聊天记录', { exact: true }).click();
await page.getByRole('button', { name: '筛选日期与状态', exact: true }).click();
const start = page.locator('.conversation-filter input[type=date]').first();
const end = page.locator('.conversation-filter input[type=date]').last();
assert.equal(await start.isVisible(), true); assert.equal(await end.isVisible(), true);
await start.fill('2026-10-01');
await Promise.all([page.waitForResponse(response => response.url().includes('/conversations') && response.url().includes('to=')), end.fill('2026-10-04')]);
assert.ok(requests.some(request => request.path.endsWith('/conversations')));
await page.screenshot({ path: resolve(output, 'dates-1165x747.png') });
checks.push('QA08: date inputs are visible and trigger filtered conversation requests');
await open('messages');
for (const label of ['已提交', '核验中', '执行中']) await page.getByText(new RegExp(label)).first().waitFor();
assert.doesNotMatch(await page.locator('.cp-stack').innerText(), /SUBMITTED|VERIFYING|EXECUTING/);
checks.push('QA09: reminder history displays Chinese intermediate privacy states');
await open('members');
await page.getByRole('button', { name: /创建另一个家庭/ }).click();
await page.getByRole('dialog', { name: '新增家庭' }).locator('input').fill('QA新家庭');
await page.getByRole('button', { name: '下一步确认', exact: true }).click();
const confirmation = page.locator('#family-controlled-operation');
await confirmation.locator('input[type=checkbox]').check();
await confirmation.getByRole('button', { name: '确认创建并成为所有者', exact: true }).click();
await page.locator('.workspace__action-message').filter({ hasText: '已创建' }).waitFor();
await selectFamily('QA家庭A');
await page.waitForFunction(() => !document.querySelector('.workspace__action-message'));
checks.push('QA07: success notice clears when family context changes');
await page.getByRole('button', { name: '编辑名称' }).click();
await page.getByRole('dialog', { name: '编辑家庭' }).locator('input').fill('QA家庭A改名');
await page.getByRole('dialog', { name: '编辑家庭' }).getByRole('button', { name: '保存', exact: true }).click();
await page.locator('.workspace__action-message').waitFor();
await page.waitForFunction(() => !document.querySelector('.workspace__action-message'), undefined, { timeout: 9000 });
checks.push('QA07: success notice expires automatically');
assert.deepEqual(problems, []);
await writeFile(resolve(output, 'result.json'), JSON.stringify({ passed: true, checks, problems, syntheticOnly: true }, null, 2));
console.log(JSON.stringify({ passed: true, checks, problems }, null, 2));
} catch (error) {
if (page) await page.screenshot({ path: resolve(output, 'failure.png') });
await writeFile(resolve(output, 'result.json'), JSON.stringify({ passed: false, checks, problems, error: String(error), body: page ? await page.locator('body').innerText() : '', requests }, null, 2));
throw error;
} finally { await browser.close(); await server.close(); }