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-privacy/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: 5328, strictPort: true }, plugins: [{ name: 'dot-privacy-regression', configureServer(server) { server.middlewares.use('/__dot-privacy-test', (_req, res) => { res.setHeader('Content-Type', 'text/html'); res.end('
'); }); }, resolveId(id) { if (id === '__dot-privacy-module.tsx') return '\0__dot-privacy-module.tsx'; }, load(id) { if (id === '\0__dot-privacy-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')||'privacy'); 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 = [], calls = []; const capabilities = ['household.read', 'household.manage', 'member.manage', 'subject.read', 'subject.write', 'privacy.read', 'privacy.manage', 'conversation.read', 'story.review']; const families = ['A', 'B'].map(id => ({ id, name: `QA家庭${id}`, tenant_id: 'qa-tenant', status: 'active', role: 'owner', capabilities, version: 1 })); const subject = { id: 'qa-deleted-elder', household_id: 'A', preferred_name: 'QA合成长者', language: 'zh-CN', timezone: 'Asia/Shanghai', profile_data: {}, conversation_preferences: {}, status: 'active', version: 1 }; const now = new Date().toISOString(); const makeRequest = status => ({ id: 'qa-delete-request', subject_id: subject.id, request_type: 'DELETE', scope: {}, status, legal_hold: false, result_summary: {}, version: 1, created_at: now, updated_at: now, subtasks: ['postgresql', 'redis', 'object_storage', 'pgvector', 'provider_index', 'export_files'].map((subsystem, index) => ({ id: `qa-subtask-${index}`, subsystem, target_type: 'subject', target_ref: subject.id, status: subsystem === 'provider_index' && status !== 'COMPLETED' ? status === 'EXECUTING' ? 'RUNNING' : 'FAILED' : 'COMPLETED', attempts: subsystem === 'provider_index' ? 8 : 1, ...(subsystem === 'provider_index' && status !== 'COMPLETED' ? { last_error: 'external index rate limit (429)' } : {}) })) }); let currentRequest = { ...makeRequest('PARTIAL_FAILED'), status: 'EXECUTING' }, subjectPresent = false, detailFail = false, listFail = false; let page; try { page = await browser.newPage({ viewport: { width: 1165, height: 747 } }); page.setDefaultTimeout(12000); 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(); calls.push({ path, method }); let data = { items: [] }; if (!subjectPresent && method === 'GET' && path.startsWith(`/api/v1/subjects/${subject.id}/`)) return route.fulfill({ status: 403, json: { ok: false, error: { code: 'FORBIDDEN', message: '合成测试:已删除长者不可读' } } }); if (path === '/api/v1/households') data = { items: families }; if (path === '/api/v1/households/A/subjects') data = { items: subjectPresent ? [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 === '/api/v1/households/A/privacy-requests') { if (listFail) return route.fulfill({ status: 503, json: { ok: false, error: { code: 'TEMPORARY', message: '合成测试:列表暂时不可用' } } }); data = { items: currentRequest ? [currentRequest] : [] }; } if (path === '/api/v1/privacy-requests/qa-delete-request') { if (detailFail) return route.fulfill({ status: 503, json: { ok: false, error: { code: 'TEMPORARY', message: '合成测试:详情暂时不可用' } } }); data = currentRequest; } if (path.endsWith('/events/history')) data = { items: currentRequest ? [{ id: 1, type: 'privacy_request.updated', household_id: 'A', subject_id: subject.id, resource_type: 'privacy_request', resource_id: currentRequest.id, status: 'EXECUTING', 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 (path === `/api/v1/subjects/${subject.id}/privacy-requests` && method === 'POST') { assert.equal(request.postDataJSON().request_type, 'DELETE'); subjectPresent = false; currentRequest = makeRequest('EXECUTING'); data = currentRequest; } 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:5328/__dot-privacy-test?section=${section}`); await page.getByRole('button', { name: '切换家庭', exact: true }).waitFor(); }; const card = () => page.getByRole('article', { name: '数据申请 qa-delete-request' }); 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('privacy'); await card().waitFor(); assert.match(await card().innerText(), /已移除长者/); assert.match(await card().innerText(), /请求编号:qa-delete-request/); assert.match(await card().innerText(), /qa-subtask-4 · 已尝试 8 次/); assert.match(await card().innerText(), /external index rate limit \(429\)/); assert.match(await card().innerText(), /不能确认数据已全部清除/); assert.equal(await page.getByRole('button', { name: '了解删除流程' }).isDisabled(), true); assert.equal(calls.filter(call => call.path.startsWith(`/api/v1/subjects/${subject.id}/`)).length, 0); await page.screenshot({ path: resolve(output, 'deleted-subject-request.png'), fullPage: true }); checks.push('QA10: deleted subject has no picker entry, but household request ID, six subtasks and failure details remain readable'); await open('home'); await page.getByText('1项数据申请需要关注', { exact: false }).waitFor(); checks.push('QA10: failed subtask overrides legacy EXECUTING; deleted-subject request remains visible on home as needing attention'); currentRequest = makeRequest('PARTIAL_FAILED'); await open('messages'); await page.getByText('数据请求需要关注', { exact: true }).waitFor(); assert.match(await page.locator('.cp-message-summary').innerText(), /未完成数据申请\s*1/); const historyNotice = page.getByRole('button').filter({ hasText: '隐私请求状态变化' }); assert.match(await historyNotice.innerText(), /部分失败/); assert.doesNotMatch(await historyNotice.innerText(), /执行中/); const beforeDetail = calls.length; await historyNotice.click(); await card().waitFor(); await page.waitForFunction(() => document.querySelector('article[aria-current="true"]')); await page.waitForFunction(() => document.activeElement?.id === 'privacy-request-qa-delete-request'); assert.ok(calls.slice(beforeDetail).some(call => call.path === '/api/v1/privacy-requests/qa-delete-request')); assert.equal(calls.slice(beforeDetail).filter(call => call.path.startsWith(`/api/v1/subjects/${subject.id}/`)).length, 0); checks.push('QA10: partial failures count as unfinished; stale EXECUTING event resolves current state and opens request ID directly'); detailFail = true; await card().getByRole('button', { name: '刷新请求状态' }).click(); await page.getByText('请求详情暂时不可用', { exact: true }).waitFor(); assert.match(await page.locator('.privacy-layout').innerText(), /请求 qa-delete-request 的最新状态读取失败/); assert.equal(await card().count(), 1); detailFail = false; currentRequest = makeRequest('COMPLETED'); await page.locator('.state-panel').filter({ hasText: '请求详情暂时不可用' }).getByRole('button', { name: '重新加载' }).click(); await card().getByText('删除对账已完成。', { exact: false }).waitFor(); assert.equal(await page.getByText('请求详情暂时不可用', { exact: true }).count(), 0); await open('messages'); await page.waitForFunction(() => /未完成数据申请\s*0/.test(document.querySelector('.cp-message-summary')?.textContent ?? '')); assert.match(await page.getByRole('button').filter({ hasText: '隐私请求状态变化' }).innerText(), /已完成/); checks.push('QA10: detail fetch failure retains the request ID; retry reads completed result and updates unfinished count to zero'); await open('privacy'); await card().waitFor(); await selectFamily('QA家庭B'); await page.getByText('本家庭暂无数据申请。', { exact: true }).waitFor(); assert.equal(await card().count(), 0); await selectFamily('QA家庭A'); await card().waitFor(); checks.push('QA10: request history is isolated by household and restored when switching back'); listFail = true; await open('messages'); await page.waitForFunction(() => /未完成数据申请\s*读取失败/.test(document.querySelector('.cp-message-summary')?.textContent ?? '')); await page.getByRole('button').filter({ hasText: '隐私请求状态变化' }).click(); await card().waitFor(); assert.match(await card().innerText(), /删除对账已完成/); checks.push('QA10: failed list fetch shows unavailable count; direct request detail remains readable independently'); listFail = false; subjectPresent = true; currentRequest = undefined; await open('privacy'); const deleteButton = page.getByRole('button', { name: '了解删除流程' }); await page.waitForFunction(() => [...document.querySelectorAll('button')].some(button => button.textContent === '了解删除流程' && !button.disabled)); await deleteButton.click(); const dialog = page.getByRole('form', { name: '永久删除主体确认' }); await dialog.getByLabel('删除原因', { exact: true }).fill('合成测试,核验请求可跟踪'); await dialog.locator('input').fill(subject.preferred_name); await dialog.getByRole('button', { name: '确认永久删除', exact: true }).click(); await card().waitFor(); assert.match(await page.locator('.privacy-layout').innerText(), /后台仍在处理|部分子任务未完成/); assert.doesNotMatch(await card().innerText(), /删除对账已完成/); await page.reload(); await card().waitFor(); assert.match(await card().innerText(), /已移除长者/); assert.equal(await page.getByText('合成测试:已删除长者不可读', { exact: false }).count(), 0); checks.push('QA10: delete submission keeps receipt immediately, does not claim completion, and survives reload after subject removal'); currentRequest = makeRequest('COMPLETED'); await card().getByText('删除对账已完成。', { exact: false }).waitFor({ timeout: 10000 }); assert.match(await card().innerText(), /6\/6/); checks.push('QA10: household polling observes terminal reconciliation without a subject or a new delete submission'); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: resolve(output, 'deleted-subject-mobile.png'), fullPage: true }); assert.ok(await page.locator('.privacy-layout').evaluate(node => node.scrollWidth <= node.clientWidth + 1)); assert.equal(calls.filter(call => call.method === 'POST').length, 1); assert.deepEqual(problems, []); checks.push('QA10: request details fit mobile width; only the explicit synthetic deletion writes data'); await writeFile(resolve(output, 'result.json'), JSON.stringify({ passed: true, checks, problems, syntheticOnly: true }, null, 2)); console.log(JSON.stringify({ passed: true, checks, problems, output }, null, 2)); } catch (error) { if (page) await page.screenshot({ path: resolve(output, 'failure.png'), fullPage: true }).catch(() => {}); await writeFile(resolve(output, 'result.json'), JSON.stringify({ passed: false, checks, problems, error: String(error) }, null, 2)); throw error; } finally { await browser.close(); await server.close(); }