Files

168 lines
14 KiB
JavaScript
Raw Permalink Normal View History

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('<!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-privacy-module.tsx"></script>'); }); },
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(); }