181 lines
15 KiB
JavaScript
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(); }
|