feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
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.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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(); }
|
||||
Reference in New Issue
Block a user