// Synthetic API responses only; verifies the real companion-preparation entry. import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { mkdir, writeFile } from 'node:fs/promises'; import { realpathSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { installCustomerFixture, currentUser, households, id } from './fixtures/customer-mobile.mjs'; 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.SUBJECT_CONSENT_OUTPUT ?? '../.local-evidence/subject-consent-checklist-20261005'); 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: 5341, strictPort: true, fs: { allow: [resolve('web'), realpathSync(resolve('web/node_modules'))] } }, plugins: [{ name: 'subject-consent-harness', configureServer(s) { s.middlewares.use('/__subject-consent-test', (_q, r) => { r.setHeader('Content-Type', 'text/html'); r.end('
'); }); }, resolveId(name) { if (name === '__subject-consent-module.tsx') return '\0__subject-consent-module.tsx'; }, load(name) { if (name === '\0__subject-consent-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';createRoot(document.getElementById('root')).render(React.createElement(React.StrictMode,null,React.createElement(SaaSWorkspace,{accessToken:'synthetic-consent-token',accounts:${JSON.stringify(currentUser.accounts)},section:'subjects',redesignedHome:true,showNavigation:false})));`; }, }] }); await server.listen(); let browser; try { browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' }); const context = await browser.newContext({ viewport: { width: 1440, height: 1024 } }); await installCustomerFixture(context, { authenticated: false }); const page = await context.newPage(), errors = [], assetErrors = [], writes = [], grants = new Map(); let viewer = false, failRead = false, failType = '', holdWrite = false, releaseWrite, nextID = 990; const response = (route, data, status = 200, code = 'OK') => route.fulfill({ status, json: { ok: status === 200, data, code, message: code, request_id: 'synthetic-checklist' } }); function grant(subjectID, type, patch = {}) { const item = { id: id(nextID++), subject_id: subjectID, type, effective: true, version: 1, granted_at: new Date(Date.now() - 1000).toISOString(), policy_version: '2026-09', ...patch }; grants.set(subjectID, [...(grants.get(subjectID) ?? []), item]); return item; } page.on('pageerror', e => errors.push(e.message)); page.on('response', response => { if (response.status() >= 400 && !new URL(response.url()).pathname.startsWith('/api/v1/')) assetErrors.push(response.url()); }); await page.route('**/api/v1/**', async route => { const req = route.request(), path = new URL(req.url()).pathname; if (path === '/api/v1/households') return response(route, { items: households.map(h => viewer ? { ...h, role: 'viewer', capabilities: ['household.read', 'subject.read', 'conversation.read'] } : h) }); if (/\/subjects\/[^/]+\/consents(?:\/[^/]+\/withdraw)?$/.test(path)) { const subjectID = path.split('/')[4]; if (req.method() === 'POST') { const body = req.postDataJSON(); writes.push({ path, subjectID, body }); if (holdWrite) await new Promise(resolve => { releaseWrite = resolve; }); if (path.endsWith('/withdraw')) { const item = grants.get(subjectID).find(item => item.id === path.split('/')[6]); Object.assign(item, { effective: false, withdrawn_at: new Date().toISOString() }); return response(route, item); } if (body.type === failType) return response(route, {}, 503, 'UNAVAILABLE'); assert(!(grants.get(subjectID) ?? []).some(item => item.type === body.type && item.effective && !item.withdrawn_at && (!item.expires_at || Date.parse(item.expires_at) > Date.now())), 'duplicate effective grant'); return response(route, grant(subjectID, body.type, body)); } return response(route, { items: grants.get(subjectID) ?? [] }, failRead ? 503 : 200, failRead ? 'UNAVAILABLE' : 'OK'); } if (path.endsWith('/events') || path.endsWith('/events/history') || path.endsWith('/firmware') || path.endsWith('/commands') || path.endsWith('/home-overview')) return response(route, { items: [] }); return route.fallback(); }); const dialog = page.getByRole('dialog', { name: /的数据授权$/ }); const checkbox = name => dialog.getByRole('checkbox', { name, exact: true }); const save = () => dialog.getByRole('button', { name: '确认保存', exact: true }); async function open() { await page.locator('.cp-preparation').getByRole('button', { name: /数据授权/ }).click(); await dialog.waitFor(); await checkbox('家庭分享').waitFor(); } async function reload() { await page.reload(); await page.getByRole('heading', { name: '陪伴准备', exact: true }).waitFor(); } await page.goto('http://127.0.0.1:5341/__subject-consent-test'); await open(); assert.equal(await dialog.getByRole('checkbox').count(), 5); assert.equal(await dialog.locator('input:checked').count(), 0); assert(await save().isDisabled()); await checkbox('保存聊天文字').check(); await checkbox('保存聊天录音').check(); assert.equal(writes.length, 0); await dialog.getByRole('button', { name: '取消', exact: true }).click(); await open(); assert.equal(await dialog.locator('input:checked').count(), 0); await checkbox('保存聊天文字').check(); await checkbox('保存聊天录音').check(); await page.screenshot({ path: resolve(output, 'desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: resolve(output, 'mobile.png') }); assert(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth + 1)); assert(await save().isVisible()); await page.setViewportSize({ width: 1440, height: 1024 }); holdWrite = true; await save().click(); await page.waitForFunction(() => document.querySelector('dialog[open] button[type=submit]')?.textContent === '正在保存…'); await dialog.locator('form').evaluate(el => { el.requestSubmit(); el.requestSubmit(); }); await page.keyboard.press('Escape'); assert(await dialog.isVisible()); await page.waitForTimeout(100); assert.equal(writes.length, 1); holdWrite = false; releaseWrite(); await dialog.waitFor({ state: 'hidden' }); assert.equal(writes.length, 2); for (const { body, subjectID } of writes) { assert.equal(subjectID, id(400)); assert.equal(body.policy_version, '2026-09'); assert.match(body.notice_digest, /^[0-9a-f]{64}$/); assert.equal(body.evidence_ref, 'family-web-confirmation'); } await open(); assert.equal(await dialog.locator('input:checked').count(), 2); assert(await save().isDisabled()); await checkbox('保存聊天录音').uncheck(); await dialog.getByText(/正在使用它的聊天可能结束/).waitFor(); await save().click(); await dialog.waitFor({ state: 'hidden' }); assert.equal(writes.length, 3); assert(writes[2].path.endsWith('/withdraw')); await open(); await checkbox('个性化记忆').check(); await checkbox('保存聊天文字').uncheck(); await dialog.getByText(/无法从新聊天中生成故事或记忆/).waitFor(); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); assert.equal(writes.length, 3); // A partial save retains the user's remaining choices and refreshes successful items before retry. await open(); await checkbox('保存聊天录音').check(); await checkbox('整理故事与摘要').check(); await checkbox('个性化记忆').check(); failType = 'story_extraction'; await save().click(); await dialog.getByRole('alert').getByText(/部分调整已保存/).waitFor(); await checkbox('保存聊天录音').waitFor(); assert(await checkbox('保存聊天录音').isChecked()); assert(await checkbox('整理故事与摘要').isChecked()); const rawCount = writes.filter(item => item.body?.type === 'raw_audio_storage').length; failType = ''; await save().click(); await dialog.waitFor({ state: 'hidden' }); assert.equal(writes.filter(item => item.body?.type === 'raw_audio_storage').length, rawCount); await open(); assert.equal(await dialog.locator('input:checked').count(), 4); await dialog.getByRole('button', { name: '取消', exact: true }).click(); // A concurrent grant is detected before writing; unchanged authorizations are preserved. await open(); await checkbox('家庭分享').check(); grant(id(400), 'family_sharing'); const beforeConcurrent = writes.length; await save().click(); await dialog.waitFor({ state: 'hidden' }); assert.equal(writes.length, beforeConcurrent); // Failed reads never turn into an editable empty checklist. failRead = true; await reload(); await page.locator('.cp-preparation').getByRole('button', { name: /数据授权/ }).click(); await dialog.getByRole('alert').waitFor(); assert.equal(await dialog.getByRole('checkbox').count(), 0); assert(await save().isDisabled()); failRead = false; await dialog.getByRole('button', { name: '重试授权状态', exact: true }).click(); await checkbox('家庭分享').waitFor(); assert.equal(await dialog.locator('input:checked').count(), 5); await dialog.getByRole('button', { name: '取消', exact: true }).click(); // Viewing another elder has isolated state, with no writes to the previous elder. grant(id(401), 'raw_audio_storage', { expires_at: new Date(Date.now() - 1000).toISOString() }); grant(id(401), 'transcript_storage', { withdrawn_at: new Date().toISOString() }); const elderButton = page.getByRole('button', { name: '查看陈建国的资料', exact: true }); if (await elderButton.count()) await elderButton.click(); else await page.getByRole('combobox', { name: '选择长者', exact: true }).selectOption(id(401)); await open(); assert.equal(await dialog.locator('input:checked').count(), 0); await dialog.getByRole('button', { name: '取消', exact: true }).click(); viewer = true; await reload(); await open(); for (const input of await dialog.getByRole('checkbox').all()) assert(await input.isDisabled()); assert.equal(await dialog.getByRole('button', { name: '确认保存', exact: true }).count(), 0); assert.equal(writes.length, beforeConcurrent); assert.deepEqual(errors, []); assert.deepEqual(assetErrors, []); const result = { passed: true, writes: writes.length, checks: ['five independent choices', 'existing state', 'cancel and Escape', 'one confirmation', 'grant and withdrawal', 'duplicate submit protection', 'partial save retry', 'concurrent grant', 'read failure and retry', 'elder isolation', 'expired and withdrawn grants', 'viewer restrictions', 'desktop and mobile'], browserErrors: errors, assetErrors }; await writeFile(resolve(output, 'checks.json'), JSON.stringify(result, null, 2)); console.log(JSON.stringify(result)); } catch (e) { const page = browser?.contexts()[0]?.pages()[0]; if (page) { await page.screenshot({ path: resolve(output, 'failure.png'), fullPage: true }); console.log((await page.locator('body').innerText()).slice(0, 3000)); } throw e; } finally { await browser?.close(); await server.close(); }