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('
'); }); }, 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(); }