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,32 @@
|
||||
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('.tmp-tos-recordings/evidence');await mkdir(output,{recursive:true});
|
||||
const server=await createServer({configFile:false,root:resolve('.tmp-tos-recordings/candidate/web'),oxc:{jsx:{runtime:'automatic'}},logLevel:'error',server:{host:'127.0.0.1',port:5325,strictPort:true},plugins:[{
|
||||
name:'recording-test',configureServer(server){server.middlewares.use('/__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/__recording-test.tsx"></script>');});},resolveId(id){if(id==='__recording-test.tsx')return '\0__recording-test.tsx';},load(id){if(id==='\0__recording-test.tsx')return `
|
||||
import React from 'react';import {createRoot} from 'react-dom/client';import {CallRecordings} from '/src/components/CallRecordings.tsx';import '/src/components/home/customer-portal.css';
|
||||
function App(){const [sid,set]=React.useState('call1');return React.createElement('main',{className:'companion-portal',style:{maxWidth:700,margin:'30px auto',padding:20}},React.createElement('button',{onClick:()=>set('call2')},'切换通话'),React.createElement(CallRecordings,{key:sid,sessionID:sid,elderID:'elder',accessToken:'test-token'}))};createRoot(document.getElementById('root')).render(React.createElement(App));`;}}]});
|
||||
await server.listen();const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH??'C:/Program Files/Google/Chrome/Application/chrome.exe'});const page=await browser.newPage({viewport:{width:1100,height:850}});const problems=[],checks=[];page.on('pageerror',e=>problems.push(e.message));
|
||||
let canPlay=false,canDownload=false,slow=false,denied=false,playbackReady=true;const requests=[];
|
||||
await page.route('**/api/v1/**',async route=>{const path=new URL(route.request().url()).pathname,body=route.request().postDataJSON();requests.push({path,body});
|
||||
if(path.endsWith('recording-access-grants')){if(body.permission==='PLAY')canPlay=true;else canDownload=true;return route.fulfill({json:{data:{}}});}
|
||||
if(path.endsWith('calls/recordings'))return route.fulfill({json:{data:{items:body.business_session_id==='call1'?[{id:'user',speaker:'USER',duration_seconds:58,bytes:1045859},{id:'bot',speaker:'ASSISTANT',duration_seconds:103,bytes:1845555}]:[],can_play:canPlay,can_download:canDownload,can_manage:true,consent:true,requester_id:'self',state:'READY',playback_ready:playbackReady}}});
|
||||
if(path.endsWith('calls/recording-access')){if(slow)await new Promise(r=>setTimeout(r,450));if(denied)return route.fulfill({status:403,json:{code:'RECORDING_ACCESS_DENIED',message:'denied'}});return route.fulfill({json:{data:{access_url:'https://memory927.tos-cn-beijing.volces.com/test.mp4?signature=synthetic',access_until:new Date(Date.now()+300000).toISOString()}}});}
|
||||
return route.fulfill({status:404,json:{}});});
|
||||
// Synthetic metadata only. No actual customer audio is loaded in this test.
|
||||
await page.route('https://memory927.tos-cn-beijing.volces.com/**',route=>route.abort());
|
||||
try{
|
||||
await page.goto('http://127.0.0.1:5325/__test');await page.getByText('长者',{exact:true}).waitFor();assert(await page.getByRole('button',{name:'播放录音'}).first().isDisabled());checks.push('separate speakers and authorization required');
|
||||
await page.getByRole('button',{name:'授权本账号播放 24 小时'}).click();await page.getByRole('button',{name:'播放录音'}).first().waitFor();await page.getByRole('button',{name:'播放录音'}).first().click();await page.locator('audio').waitFor();assert.match(await page.locator('audio').getAttribute('src'),/^https:\/\/memory927\.tos-cn-beijing\.volces\.com\//);assert.equal(await page.locator('audio').getAttribute('preload'),'none');checks.push('player uses direct TOS URL');
|
||||
await page.getByRole('button',{name:'授权本账号下载 24 小时'}).click();await page.getByRole('button',{name:'获取下载链接'}).first().click();await page.getByRole('link',{name:'从火山下载录音'}).waitFor();assert.match(await page.getByRole('link',{name:'从火山下载录音'}).getAttribute('href'),/^https:\/\/memory927\.tos-cn-beijing\.volces\.com\//);checks.push('download is a direct link, not a platform blob');
|
||||
await page.screenshot({path:resolve(output,'recordings-desktop.png'),fullPage:true});await page.setViewportSize({width:390,height:844});assert(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)<3);await page.screenshot({path:resolve(output,'recordings-mobile.png'),fullPage:true});checks.push('mobile layout');
|
||||
denied=true;await page.getByRole('button',{name:'播放录音'}).first().click();await page.getByRole('alert').waitFor();assert.equal(await page.locator('audio').count(),0);assert.equal(await page.getByRole('link',{name:'从火山下载录音'}).count(),0);checks.push('revoked access clears prior addresses');
|
||||
denied=false;slow=true;await page.getByRole('button',{name:'播放录音'}).first().click();await page.getByRole('button',{name:'切换通话'}).click();await page.getByText(/暂未找到录音/).waitFor();await page.waitForTimeout(650);assert.equal(await page.locator('audio').count(),0);checks.push('late URL cannot leak into another call');
|
||||
playbackReady=false;await page.reload();await page.getByText(/在线播放尚未开通/).waitFor();assert.equal(await page.getByRole('button',{name:'播放录音'}).count(),0);assert.equal(await page.getByRole('button',{name:'获取下载链接'}).count(),2);checks.push('default TOS domain exposes download without a broken play button');
|
||||
assert.deepEqual(problems,[]);await writeFile(resolve(output,'result.json'),JSON.stringify({checks,problems},null,2));console.log(JSON.stringify({checks,problems}));
|
||||
}catch(e){console.log(JSON.stringify({problems,requests,body:await page.locator('body').innerText()}));throw e;}finally{await browser.close();await server.close();}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Real customer workspace, synthetic responses only. No production requests.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
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('.design-evidence/consent-guidance');
|
||||
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: 5340, strictPort: true }, plugins: [{
|
||||
name: 'consent-guidance-harness',
|
||||
configureServer(s) { s.middlewares.use('/__consent-test', (_q, r) => { r.setHeader('Content-Type', 'text/html'); r.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/__consent-module.tsx"></script>'); }); },
|
||||
resolveId(name) { if (name === '__consent-module.tsx') return '\0__consent-module.tsx'; },
|
||||
load(name) { if (name === '\0__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';function App(){const [section,setSection]=React.useState('conversations');return React.createElement(React.Fragment,null,React.createElement('nav',null,['conversations','stories','subjects','privacy','sharing'].map(value=>React.createElement('button',{key:value,onClick:()=>setSection(value)},'测试'+value))),React.createElement(SaaSWorkspace,{accessToken:'synthetic-consent-token',accounts:${JSON.stringify(currentUser.accounts)},section,onSectionChange:setSection,redesignedHome:true,showNavigation:false}));};createRoot(document.getElementById('root')).render(React.createElement(React.StrictMode,null,React.createElement(App)));`; },
|
||||
}] });
|
||||
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: 1000 } });
|
||||
await installCustomerFixture(context, { authenticated: false });
|
||||
const page = await context.newPage(), errors = [], writes = [], grants = new Map();
|
||||
const session = '11111111-1111-4111-8111-111111111111';
|
||||
let failed = false, viewer = false, transcriptDenied = false, recordingState = 'CONSENT_REQUIRED', holdSubject = '', releaseSubject;
|
||||
const response = (route, data, status = 200, code = 'OK') => route.fulfill({ status, json: { ok: status === 200, data, code, message: code, request_id: 'synthetic-consent' } });
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
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$/.test(path)) {
|
||||
const subjectID = path.split('/')[4];
|
||||
if (req.method() === 'POST') {
|
||||
const body = req.postDataJSON(); writes.push({ subjectID, body });
|
||||
const item = { ...body, id: id(999), subject_id: subjectID, actor_user_id: id(100), granted_at: new Date().toISOString(), effective: true, version: 1 };
|
||||
grants.set(subjectID, [...(grants.get(subjectID) ?? []), item]); return response(route, item);
|
||||
}
|
||||
if (subjectID === holdSubject) await new Promise(resolve => { releaseSubject = resolve; });
|
||||
return response(route, { items: grants.get(subjectID) ?? [] }, failed ? 503 : 200, failed ? 'UNAVAILABLE' : 'OK');
|
||||
}
|
||||
if (path.startsWith('/api/v1/memories/')) {
|
||||
const op = path.split('/memories/')[1];
|
||||
if (op === 'calls/query') return response(route, { items: [{ id: session, status: 'ENDED', transcript_state: 'PARTIAL', created_at: new Date().toISOString(), ended_at: new Date().toISOString(), duration_ms: 30000 }], next_cursor: '' });
|
||||
if (op === 'calls/turns') return response(route, { items: [], next_cursor: '' }, transcriptDenied ? 403 : 200, transcriptDenied ? 'TRANSCRIPT_ACCESS_DENIED' : 'OK');
|
||||
if (op === 'calls/recordings') return response(route, { items: [], state: recordingState, can_play: false, can_download: false, playback_ready: true, can_manage: !viewer, requester_id: id(100) });
|
||||
if (op === 'query') return response(route, { items: [], mutations: [], next_cursor: '', can_write: false, can_manage_access: false });
|
||||
if (op === 'sync/status') return response(route, { memory_revision: 0, viking_visible_revision: 0, pending_count: 0, blocked_count: 0, upload_pending_count: 0, extracting_count: 0, sync_state: 'EMPTY' });
|
||||
throw new Error(`Unexpected memory operation ${op}`);
|
||||
}
|
||||
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 guidance = page.getByRole('region', { name: '当前功能的授权提示' });
|
||||
await page.goto('http://127.0.0.1:5340/__consent-test');
|
||||
await guidance.getByText(/目前无法保存新聊天的文字/).waitFor();
|
||||
await page.screenshot({ path: resolve(output, 'owner-shortcuts.png'), fullPage: true });
|
||||
await page.getByText('文字不完整或未保存', { exact: false }).first().waitFor();
|
||||
assert.equal(await page.getByText('已保存部分文字', { exact: true }).count(), 0);
|
||||
await page.getByRole('button', { name: /30 秒/ }).click();
|
||||
await page.getByText(/如果聊天时未授权,文字不会保存/).waitFor();
|
||||
await page.getByRole('region', { name: '本次聊天录音' }).getByRole('button', { name: '去授权保存原始录音' }).waitFor();
|
||||
await guidance.getByRole('button', { name: '去授权保存文字记录', exact: true }).click();
|
||||
let dialog = page.getByRole('dialog');
|
||||
assert(await dialog.getByRole('button', { name: '确认授权', exact: true }).isDisabled());
|
||||
await page.waitForTimeout(300); // Capture the settled modal after its entrance animation.
|
||||
await page.screenshot({ path: resolve(output, 'grant-dialog.png'), fullPage: true });
|
||||
await dialog.getByRole('button', { name: /^取\s*消$/ }).click();
|
||||
assert.equal(writes.length, 0);
|
||||
await guidance.getByRole('button', { name: '去授权保存文字记录', exact: true }).click();
|
||||
dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('checkbox').check();
|
||||
await dialog.getByRole('button', { name: '确认授权', exact: true }).click();
|
||||
await guidance.getByRole('button', { name: '去授权保存文字记录', exact: true }).waitFor({ state: 'hidden' });
|
||||
assert.equal(writes.length, 1); assert.equal(writes[0].subjectID, id(400));
|
||||
assert.equal(writes[0].body.type, 'transcript_storage'); assert.equal(writes[0].body.policy_version, '2026-09'); assert.match(writes[0].body.notice_digest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(grants.get(id(400)).length, 1);
|
||||
await page.getByRole('button', { name: '测试stories', exact: true }).click();
|
||||
await guidance.getByText(/无法在新聊天中使用长期记忆/).waitFor();
|
||||
await guidance.getByText(/不会自动整理为故事候选或摘要/).waitFor();
|
||||
await page.getByText(/当前账号只有查看权限/).waitFor();
|
||||
await guidance.getByRole('button', { name: '去授权个性化记忆', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: /^取\s*消$/ }).click();
|
||||
await page.getByRole('button', { name: '测试conversations', exact: true }).click();
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden' });
|
||||
holdSubject = id(401);
|
||||
await page.getByRole('combobox', { name: '选择长者', exact: true }).selectOption(id(401));
|
||||
await page.getByText('正在核对数据授权…', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole('button', { name: '去授权保存文字记录', exact: true }).count(), 0);
|
||||
while (!releaseSubject) await new Promise(resolve => setTimeout(resolve, 20));
|
||||
holdSubject = ''; releaseSubject();
|
||||
await guidance.getByRole('button', { name: '去授权保存文字记录', exact: true }).waitFor();
|
||||
await guidance.getByRole('button', { name: '去授权保存文字记录', exact: true }).click();
|
||||
await page.getByRole('dialog').getByText('陈建国', { exact: true }).waitFor();
|
||||
await page.getByRole('dialog').getByRole('button', { name: /^取\s*消$/ }).click();
|
||||
failed = true; await page.reload();
|
||||
await page.getByText('暂时无法读取授权状态,请重试后核对。', { exact: false }).waitFor();
|
||||
assert.equal(await page.getByText(/因为尚未授权“保存文字记录”/).count(), 0);
|
||||
failed = false; await page.getByRole('button', { name: '重试授权状态', exact: true }).click();
|
||||
await guidance.waitFor();
|
||||
viewer = true; grants.clear(); recordingState = 'READY'; transcriptDenied = true;
|
||||
await page.reload();
|
||||
await guidance.getByText(/当前账号不能新增数据授权/).waitFor();
|
||||
assert.equal(await guidance.getByRole('button', { name: /^去授权/ }).count(), 0);
|
||||
await page.getByRole('button', { name: /30 秒/ }).click();
|
||||
await page.getByText(/查看设备聊天文字需要/).waitFor();
|
||||
await page.getByText(/当前账号没有播放权限/).waitFor();
|
||||
await page.getByText(/当前账号没有下载权限/).waitFor();
|
||||
assert.equal(await page.getByRole('button', { name: /^授权本账号/ }).count(), 0);
|
||||
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'), fullPage: true });
|
||||
assert((await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)) < 3);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: missing consent and historical gaps; explicit single-item grant/cancel; dependent features; subject isolation; fetch failure/retry; viewer restrictions; transcript/recording access instructions; mobile layout');
|
||||
} finally { await browser?.close(); await server.close(); }
|
||||
@@ -0,0 +1,357 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve, join, dirname } 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 work = await mkdtemp(join(tmpdir(), 'story-dialog-focus-'));
|
||||
const evidenceDir = process.env.SUBJECT_CANCEL_EVIDENCE_DIR;
|
||||
if (evidenceDir) await mkdir(evidenceDir, {recursive:true});
|
||||
await mkdir(join(work, 'empty-env'));
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('web'), envDir: join(work, 'empty-env'), cacheDir: join(work, 'vite-cache'),
|
||||
oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 },
|
||||
plugins: [{
|
||||
name: 'creation-dialog-focus-fixture', enforce: 'pre',
|
||||
transform(code,id) {
|
||||
if (!id.replaceAll('\\','/').split('?')[0].endsWith('/src/pages/SaaSWorkspace.tsx')) return;
|
||||
// Control only the asynchronous image processor, while retaining the
|
||||
// actual file input, React handler, scope checks, and preview rendering.
|
||||
const marker='async function prepareAvatarDataURL(file: File) {';
|
||||
assert.ok(code.includes(marker));
|
||||
return {code:code.replace(marker,marker+'\n if (typeof window.__avatarTestProcess === "function") return window.__avatarTestProcess(file);'),map:null};
|
||||
},
|
||||
configureServer(vite) { vite.middlewares.use('/__dialog-test', (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.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/__dialog-fixture.tsx"></script>');
|
||||
}); },
|
||||
resolveId(id) { if (id === '__dialog-fixture.tsx') return '\0__dialog-fixture.tsx'; },
|
||||
load(id) { if (id === '\0__dialog-fixture.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';
|
||||
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')||'subjects');
|
||||
const [token,setToken]=React.useState('synthetic-original');
|
||||
window.changeTestToken=()=>setToken('synthetic-next');
|
||||
return React.createElement(SaaSWorkspace,{accounts,accessToken:token,section,onSectionChange:setSection,redesignedHome:true,showNavigation:false});
|
||||
} createRoot(document.getElementById('root')).render(React.createElement(App));
|
||||
`; },
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
const browser = await chromium.launch({ headless: true, ...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}) });
|
||||
const errors = [], blocked = [], checks = [];
|
||||
const capabilities = ['household.read','household.manage','member.manage','ownership.transfer','subject.read','subject.write','privacy.read','privacy.manage','conversation.read','story.review'];
|
||||
const family = id => ({ id, name:`测试家庭${id}`, tenant_id:'qa-tenant', status:'active', role:'owner', capabilities, version:1 });
|
||||
const elder = id => ({id:`elder-${id}`,household_id:id,preferred_name:`测试长者${id}`,language:'zh-CN',timezone:'Asia/Shanghai',profile_data:{background:'合成背景',interests:[]},conversation_preferences:{pace:'slow',conversation_goal:'日常陪伴',preferred_topics:[],avoided_topics:[]},status:'active',version:1});
|
||||
async function fixturePage(section = 'subjects', viewport = {width:389,height:844}) {
|
||||
const context = await browser.newContext({viewport,serviceWorkers:'block'});
|
||||
await context.addInitScript(() => localStorage.setItem('companion:household:qa-tenant/qa-user','A'));
|
||||
const page = await context.newPage(); page.setDefaultTimeout(15000);
|
||||
const fixture = { creates:[], renames:[], families:[family('A'),family('B')], subjects:{A:[elder('A')],B:[elder('B')]}, holdCreate:false, holdRename:false };
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.route('**/*', async route => {
|
||||
const request=route.request(), url=new URL(request.url()), path=url.pathname, method=request.method();
|
||||
if (url.origin!==origin) { blocked.push(url.origin+path); return route.abort('blockedbyclient'); }
|
||||
if (!path.startsWith('/api/')) return route.continue();
|
||||
const body=request.postData()?request.postDataJSON():undefined;
|
||||
const answer=data=>route.fulfill({json:{ok:true,data,request_id:'synthetic-dialog'},headers:{ETag:'"v1"'}});
|
||||
if(path==='/api/v1/events') return route.fulfill({contentType:'text/event-stream',body:': synthetic fixture\n\n'});
|
||||
if(path==='/api/v1/households') return answer({items:fixture.families});
|
||||
const householdSubjects=path.match(/^\/api\/v1\/households\/([^/]+)\/subjects$/);
|
||||
if(householdSubjects && method==='GET') return answer({items:fixture.subjects[householdSubjects[1]]||[]});
|
||||
if(householdSubjects && method==='POST') {
|
||||
fixture.creates.push({path,body});
|
||||
if(fixture.holdCreate) await new Promise(resolve=>fixture.releaseCreate=resolve);
|
||||
const created={...elder(householdSubjects[1]),...body,id:'synthetic-created'};
|
||||
fixture.subjects[householdSubjects[1]].push(created); return answer(created);
|
||||
}
|
||||
const householdPatch=path.match(/^\/api\/v1\/households\/([^/]+)$/);
|
||||
if(householdPatch && method==='PATCH') {
|
||||
fixture.renames.push({path,body,ifMatch:request.headers()['if-match']});
|
||||
if(fixture.holdRename) await new Promise(resolve=>fixture.releaseRename=resolve);
|
||||
const changed={...family(householdPatch[1]),...body,version:2};
|
||||
fixture.families=fixture.families.map(item=>item.id===changed.id?changed:item); return answer(changed);
|
||||
}
|
||||
if(path.endsWith('/members')) return answer({items:[{id:'qa-user',user_id:'qa-user',display_name:'合成测试成员',role:'owner',status:'active',is_owner:true,version:1,joined_at:'2026-10-06T00:00:00Z'}]});
|
||||
if(path.endsWith('/conversation-readiness')||path.endsWith('/readiness')) return answer({subject_id:'elder-A',ready:false,checks:[],devices:[],start_time_checks:[]});
|
||||
if(method==='GET'||(method==='POST'&&path.endsWith('/query'))) return answer({items:[],next_cursor:''});
|
||||
errors.push(`Unexpected synthetic mutation: ${method} ${path}`);
|
||||
return route.fulfill({status:400,json:{ok:false,error:{code:'SYNTHETIC_REJECTED',message:'Not a fixture mutation'}}});
|
||||
});
|
||||
await page.goto(`${origin}/__dialog-test?section=${section}`);
|
||||
await page.locator('.ch-sidebar .ch-household').waitFor({state:'attached'});
|
||||
return { page, context, fixture };
|
||||
}
|
||||
const addButton = page => page.getByRole('button',{name:/添加长者|新增长者/}).first();
|
||||
const creator = page => page.locator('dialog.subject-create-dialog');
|
||||
const focusedInside = page => page.evaluate(()=>document.querySelector('dialog[open]')?.contains(document.activeElement));
|
||||
async function openCreator(page) {
|
||||
try { await addButton(page).click(); await creator(page).waitFor(); }
|
||||
catch(error) { console.error('Synthetic dialog diagnostics:',errors,blocked,(await page.locator('body').innerText()).slice(0,3000)); throw error; }
|
||||
}
|
||||
async function fillCreator(page) {
|
||||
await creator(page).getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶').fill('合成新长者');
|
||||
await creator(page).getByPlaceholder('例如:退休前是教师,年轻时在天津生活过').fill('仅本地模拟');
|
||||
}
|
||||
async function switchFamily(page,id) {
|
||||
// Simulate a scope change supplied by surrounding navigation while a modal
|
||||
// is open; native inert correctly prevents an ordinary background click.
|
||||
await page.locator('.household-tabs button').filter({hasText:`测试家庭${id}`}).evaluate(element=>element.click());
|
||||
}
|
||||
async function wrapCheck(page, dialog) {
|
||||
const first=dialog.getByRole('button',{name:/^关闭/}).first();
|
||||
const cancel=dialog.getByRole('button',{name:'取消',exact:true});
|
||||
await cancel.focus(); await page.keyboard.press('Tab');
|
||||
assert.equal(await first.evaluate(element=>element===document.activeElement),true);
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
assert.equal(await cancel.evaluate(element=>element===document.activeElement),true);
|
||||
for(let step=0;step<18;step++){await page.keyboard.press('Tab');assert.equal(await focusedInside(page),true);}
|
||||
}
|
||||
async function installAvatarJobs(page) {
|
||||
await page.evaluate(() => {
|
||||
window.avatarJobs=[];
|
||||
window.__avatarTestProcess=file=>new Promise((resolve,reject)=>window.avatarJobs.push({name:file.name,resolve,reject}));
|
||||
const canvas=document.createElement('canvas'); canvas.width=2; canvas.height=2;
|
||||
const context=canvas.getContext('2d'); window.avatarImages={};
|
||||
for(const color of ['red','blue','green']) {context.fillStyle=color;context.fillRect(0,0,2,2);window.avatarImages[color]=canvas.toDataURL('image/png');}
|
||||
});
|
||||
}
|
||||
async function queueAvatar(page,name) {
|
||||
const index=await page.evaluate(()=>window.avatarJobs.length);
|
||||
await creator(page).locator('input[type=file]').setInputFiles({name,mimeType:'image/png',buffer:Buffer.from('synthetic-image-processor-input')});
|
||||
await page.waitForFunction(count=>window.avatarJobs.length===count+1,index);
|
||||
return index;
|
||||
}
|
||||
async function finishAvatar(page,index,color,error=false) {
|
||||
await page.evaluate(async ({index,color,error})=>{
|
||||
const job=window.avatarJobs[index];
|
||||
if(error) job.reject(new Error('Synthetic stale image error')); else job.resolve(window.avatarImages[color]);
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
},{index,color,error});
|
||||
}
|
||||
const avatarSource = page => creator(page).locator('.subject-avatar-upload img').getAttribute('src');
|
||||
const draftSnapshot = page => creator(page).evaluate(dialog => ({
|
||||
fields: Array.from(dialog.querySelectorAll('input:not([type=file]), textarea, select'), element => element.value),
|
||||
avatar: dialog.querySelector('.subject-avatar-upload img')?.getAttribute('src') ?? null,
|
||||
avatarError: dialog.querySelector('.subject-avatar-error')?.textContent ?? null,
|
||||
nextDisabled: dialog.querySelector('button[type=submit]').disabled,
|
||||
}));
|
||||
async function fillFullDraft(page) {
|
||||
await fillCreator(page);
|
||||
await creator(page).getByLabel(/^交流节奏/).selectOption('lively');
|
||||
await creator(page).getByLabel(/^兴趣标签/).fill('合成兴趣');
|
||||
await creator(page).getByLabel(/^长期陪伴方向/).fill('合成陪伴方向');
|
||||
await creator(page).getByLabel(/^优先话题/).fill('合成优先话题');
|
||||
await creator(page).getByLabel(/^避免主动提及/).fill('合成避免话题');
|
||||
const job=await queueAvatar(page,'draft-avatar.png'); await finishAvatar(page,job,'blue');
|
||||
return draftSnapshot(page);
|
||||
}
|
||||
try {
|
||||
for (const viewport of [{width:389,height:844},{width:1188,height:761}]) {
|
||||
const {page,context,fixture}=await fixturePage('subjects',viewport); await installAvatarJobs(page);
|
||||
await openCreator(page); const cleanDraft=await draftSnapshot(page);
|
||||
assert.equal(cleanDraft.nextDisabled,true); assert.equal(cleanDraft.avatar,null);
|
||||
for (const dismissal of ['cancel','cancel','close','escape']) {
|
||||
await fillFullDraft(page);
|
||||
if(dismissal==='escape') await page.keyboard.press('Escape');
|
||||
else await creator(page).getByRole('button',{name:dismissal==='cancel'?'取消':'关闭新增长者弹窗',exact:true}).click();
|
||||
await creator(page).waitFor({state:'detached'}); assert.equal(fixture.creates.length,0);
|
||||
await openCreator(page); assert.deepEqual(await draftSnapshot(page),cleanDraft);
|
||||
}
|
||||
if(evidenceDir) await page.screenshot({path:join(evidenceDir,`reopened-clean-${viewport.width}.png`)});
|
||||
checks.push(`${viewport.width}px: Cancel twice, X and Escape discard all fields/avatar; repeated opens are clean and Next disabled; no creation`);
|
||||
|
||||
const filledDraft=await fillFullDraft(page), consent=page.locator('dialog.creation-consent-dialog');
|
||||
for(let round=0;round<2;round++) {
|
||||
await creator(page).getByRole('button',{name:'下一步',exact:true}).click(); await consent.waitFor();
|
||||
assert.equal(fixture.creates.length,0);
|
||||
await consent.getByRole('button',{name:'返回资料',exact:true}).click(); await creator(page).waitFor();
|
||||
assert.deepEqual(await draftSnapshot(page),filledDraft);
|
||||
}
|
||||
checks.push(`${viewport.width}px: two forward/Back round trips preserve every unsaved field/avatar without creating an entity`);
|
||||
for(const dismissal of ['close','escape']) {
|
||||
await creator(page).getByRole('button',{name:'下一步',exact:true}).click(); await consent.waitFor();
|
||||
if(dismissal==='escape') await page.keyboard.press('Escape');
|
||||
else await consent.getByRole('button',{name:'关闭开通授权',exact:true}).click();
|
||||
await consent.waitFor({state:'detached'}); assert.equal(fixture.creates.length,0);
|
||||
await openCreator(page); assert.deepEqual(await draftSnapshot(page),cleanDraft);
|
||||
if(dismissal==='close') await fillFullDraft(page);
|
||||
}
|
||||
checks.push(`${viewport.width}px: closing authorization with X/Escape abandons the entire unsaved creation, while Back keeps it`);
|
||||
for(const error of [false,true]) {
|
||||
const pending=await queueAvatar(page,`discarded-${error}.png`);
|
||||
await creator(page).getByRole('button',{name:'取消',exact:true}).click(); await creator(page).waitFor({state:'detached'});
|
||||
await openCreator(page); await finishAvatar(page,pending,'red',error);
|
||||
assert.deepEqual(await draftSnapshot(page),cleanDraft);
|
||||
}
|
||||
checks.push(`${viewport.width}px: delayed avatar success/failure after cancellation cannot revive a discarded image/error`);
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const {page,context,fixture}=await fixturePage(); await installAvatarJobs(page);
|
||||
await openCreator(page); const cleanDraft=await draftSnapshot(page);
|
||||
const filledDraft=await fillFullDraft(page); const pending=await queueAvatar(page,'pending-at-submit.png');
|
||||
await creator(page).getByRole('button',{name:'下一步',exact:true}).click();
|
||||
const consent=page.locator('dialog.creation-consent-dialog'); fixture.holdCreate=true;
|
||||
await consent.getByRole('button',{name:'暂不开通,完成创建',exact:true}).click();
|
||||
await page.waitForFunction(()=>document.querySelector('dialog.creation-consent-dialog button[type=submit]')?.disabled);
|
||||
assert.equal(await consent.getByRole('button',{name:'关闭开通授权',exact:true}).isDisabled(),true);
|
||||
assert.equal(await consent.getByRole('button',{name:'返回资料',exact:true}).isDisabled(),true);
|
||||
await page.keyboard.press('Escape'); assert.equal(await consent.count(),1);
|
||||
await consent.locator('form').dispatchEvent('submit'); assert.equal(fixture.creates.length,1);
|
||||
assert.equal(fixture.creates[0].body.preferred_name,filledDraft.fields[0]);
|
||||
assert.deepEqual(fixture.creates[0].body.initial_consents.types,[]);
|
||||
if(evidenceDir) await page.screenshot({path:join(evidenceDir,'submitting-guard.png')});
|
||||
fixture.releaseCreate(); await consent.waitFor({state:'detached'});
|
||||
await page.getByRole('heading',{name:'合成新长者',exact:true}).waitFor();
|
||||
assert.equal(fixture.subjects.A.filter(item=>item.id==='synthetic-created').length,1);
|
||||
await openCreator(page); await finishAvatar(page,pending,'red');
|
||||
assert.deepEqual(await draftSnapshot(page),cleanDraft);
|
||||
await creator(page).getByRole('button',{name:'取消',exact:true}).click(); await creator(page).waitFor({state:'detached'});
|
||||
await page.getByRole('heading',{name:'合成新长者',exact:true}).waitFor();
|
||||
assert.equal(fixture.creates.length,1); assert.equal(fixture.subjects.A.filter(item=>item.id==='synthetic-created').length,1);
|
||||
checks.push('submission blocks X/Back/Escape and duplicate submit; success keeps exactly one saved entity, resets next creation and rejects stale avatar');
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const {page,context,fixture}=await fixturePage();
|
||||
for(let cycle=0;cycle<3;cycle++) {
|
||||
await openCreator(page);
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.style.overflow),'hidden');
|
||||
await wrapCheck(page,creator(page));
|
||||
if(cycle===1) await creator(page).getByRole('button',{name:'取消',exact:true}).click();
|
||||
else await page.keyboard.press('Escape');
|
||||
await creator(page).waitFor({state:'detached'});
|
||||
assert.equal(await addButton(page).evaluate(element=>element===document.activeElement),true);
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.style.overflow),'');
|
||||
}
|
||||
assert.equal(fixture.creates.length,0);
|
||||
checks.push('389px: repeated create/cancel/Escape, Tab/Shift+Tab loop, opener focus, scroll unlock');
|
||||
await openCreator(page); await fillCreator(page);
|
||||
const bounds=await creator(page).boundingBox(); assert.ok(bounds.x>=0&&bounds.x+bounds.width<=390);
|
||||
await creator(page).getByRole('button',{name:'下一步',exact:true}).click();
|
||||
const consent=page.locator('dialog.creation-consent-dialog'); await consent.waitFor();
|
||||
assert.equal(await page.locator('dialog[open]').count(),1); assert.equal(await focusedInside(page),true);
|
||||
assert.equal(fixture.creates.length,0);
|
||||
await consent.getByRole('button',{name:'返回资料',exact:true}).click(); await creator(page).waitFor();
|
||||
assert.equal(await creator(page).getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶').inputValue(),'合成新长者');
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await addButton(page).evaluate(element=>element===document.activeElement),true);
|
||||
checks.push('creation consent handoff has one modal and keeps focus; Back preserves draft and return focus');
|
||||
for(const dismissal of ['escape','close']) {
|
||||
await openCreator(page); await fillCreator(page);
|
||||
await creator(page).getByRole('button',{name:'下一步',exact:true}).click();
|
||||
await consent.waitFor();
|
||||
if(dismissal==='escape') await page.keyboard.press('Escape');
|
||||
else await consent.getByRole('button',{name:'关闭开通授权',exact:true}).click();
|
||||
await consent.waitFor({state:'detached'});
|
||||
assert.equal(await addButton(page).evaluate(element=>element===document.activeElement),true);
|
||||
assert.equal(fixture.creates.length,0);
|
||||
}
|
||||
checks.push('cancel/Escape from consent restores the original add-elder opener; no background body focus');
|
||||
await openCreator(page); await fillCreator(page); await switchFamily(page,'B');
|
||||
await creator(page).waitFor({state:'detached'}); await openCreator(page);
|
||||
assert.equal(await creator(page).getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶').inputValue(),'');
|
||||
checks.push('household change closes creation and clears old household draft');
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const {page,context,fixture}=await fixturePage(); await installAvatarJobs(page);
|
||||
await openCreator(page); const oldFamilyJob=await queueAvatar(page,'old-family.png');
|
||||
await switchFamily(page,'B'); await creator(page).waitFor({state:'detached'}); await openCreator(page);
|
||||
const currentFamilyJob=await queueAvatar(page,'current-family.png');
|
||||
await finishAvatar(page,currentFamilyJob,'blue'); const currentFamilyAvatar=await avatarSource(page);
|
||||
await finishAvatar(page,oldFamilyJob,'red'); assert.equal(await avatarSource(page),currentFamilyAvatar);
|
||||
checks.push('a delayed avatar success from household A cannot replace household B draft image');
|
||||
|
||||
const oldTokenJob=await queueAvatar(page,'old-token.png');
|
||||
await page.evaluate(()=>window.changeTestToken()); await creator(page).waitFor({state:'detached'}); await openCreator(page);
|
||||
const currentTokenJob=await queueAvatar(page,'current-token.png');
|
||||
await finishAvatar(page,currentTokenJob,'green'); const currentTokenAvatar=await avatarSource(page);
|
||||
await finishAvatar(page,oldTokenJob,'red',true);
|
||||
assert.equal(await avatarSource(page),currentTokenAvatar);
|
||||
assert.equal(await creator(page).locator('.subject-avatar-error').count(),0);
|
||||
checks.push('a delayed avatar failure from an old token cannot clear the new image or show an old error');
|
||||
|
||||
const earlierJob=await queueAvatar(page,'earlier-selection.png');
|
||||
const laterJob=await queueAvatar(page,'later-selection.png');
|
||||
await finishAvatar(page,laterJob,'blue'); const latestAvatar=await avatarSource(page);
|
||||
await finishAvatar(page,earlierJob,'red'); assert.equal(await avatarSource(page),latestAvatar);
|
||||
const beforeRoundTrip=await queueAvatar(page,'before-family-round-trip.png');
|
||||
await switchFamily(page,'A'); await creator(page).waitFor({state:'detached'});
|
||||
await page.getByRole('heading',{name:'测试长者A',exact:true}).waitFor(); await switchFamily(page,'B');
|
||||
await page.getByRole('heading',{name:'测试长者B',exact:true}).waitFor(); await openCreator(page);
|
||||
await finishAvatar(page,beforeRoundTrip,'red');
|
||||
assert.equal(await creator(page).locator('.subject-avatar-upload img').count(),0);
|
||||
assert.equal(fixture.creates.length,0);
|
||||
checks.push('latest image selection wins; leaving and returning to a household does not revive an old avatar');
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const {page,context,fixture}=await fixturePage(); fixture.holdCreate=true;
|
||||
await openCreator(page); await fillCreator(page); await creator(page).getByRole('button',{name:'下一步',exact:true}).click();
|
||||
const consent=page.locator('dialog.creation-consent-dialog');
|
||||
await consent.getByRole('button',{name:'暂不开通,完成创建',exact:true}).click();
|
||||
await page.waitForFunction(()=>document.querySelector('dialog.creation-consent-dialog button[type=submit]')?.disabled);
|
||||
await page.keyboard.press('Escape'); assert.equal(await consent.count(),1);
|
||||
await consent.locator('form').dispatchEvent('submit');
|
||||
assert.equal(fixture.creates.length,1); assert.deepEqual(fixture.creates[0].body.initial_consents.types,[]);
|
||||
await switchFamily(page,'B'); await consent.waitFor({state:'detached'});
|
||||
fixture.releaseCreate();
|
||||
await page.getByRole('heading',{name:'测试长者B',exact:true}).waitFor();
|
||||
await openCreator(page); // Waits for busy to clear; old response must not reopen its scope.
|
||||
assert.equal(await creator(page).getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶').inputValue(),'');
|
||||
assert.doesNotMatch(await page.locator('body').innerText(),/长者档案已保存/);
|
||||
assert.equal(fixture.creates.length,1);
|
||||
checks.push('busy create is single-submit; deferred A response does not select an A elder or announce success in B');
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const {page,context,fixture}=await fixturePage();
|
||||
await openCreator(page); await fillCreator(page); await creator(page).getByRole('button',{name:'下一步',exact:true}).click();
|
||||
await page.locator('dialog.creation-consent-dialog').waitFor();
|
||||
await page.evaluate(()=>window.changeTestToken());
|
||||
await page.locator('dialog.creation-consent-dialog').waitFor({state:'detached'});
|
||||
await openCreator(page);
|
||||
assert.equal(await creator(page).getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶').inputValue(),'');
|
||||
assert.equal(fixture.creates.length,0);
|
||||
checks.push('account token change closes consent and clears unsubmitted creation data');
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const {page,context,fixture}=await fixturePage('members');
|
||||
const entry=page.getByRole('button',{name:/编辑名称/});
|
||||
for(let cycle=0;cycle<2;cycle++) {
|
||||
await entry.click(); const dialog=page.locator('dialog.household-name-dialog'); await dialog.waitFor();
|
||||
await wrapCheck(page,dialog); await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
|
||||
assert.equal(await entry.evaluate(element=>element===document.activeElement),true);
|
||||
}
|
||||
await entry.click(); const dialog=page.locator('dialog.household-name-dialog');
|
||||
await dialog.locator('input').fill('改名合成家庭'); fixture.holdRename=true;
|
||||
await dialog.getByRole('button',{name:'保存',exact:true}).click();
|
||||
await page.keyboard.press('Escape'); assert.equal(await dialog.count(),1);
|
||||
await dialog.locator('form').dispatchEvent('submit'); assert.equal(fixture.renames.length,1);
|
||||
assert.equal(fixture.renames[0].ifMatch,'"v1"'); fixture.releaseRename();
|
||||
await dialog.waitFor({state:'detached'});
|
||||
assert.equal(await entry.evaluate(element=>element===document.activeElement),true);
|
||||
checks.push('household rename shares focus behavior, preserves busy guard and If-Match, returns opener after save');
|
||||
await context.close();
|
||||
}
|
||||
assert.deepEqual(errors,[]); assert.deepEqual(blocked,[]);
|
||||
const result={ok:true,viewports:['389x844','1188x761'],checks,blockedExternal:blocked,pageErrors:errors,
|
||||
isolation:{freshEmptyViteEnv:true,syntheticApiOnly:true,productionRequests:0,deployed:false}};
|
||||
if(evidenceDir) await writeFile(join(evidenceDir,'browser-results.json'),JSON.stringify(result,null,2)+'\n');
|
||||
console.log(JSON.stringify(result,null,2));
|
||||
} finally {
|
||||
await browser.close(); await server.close();
|
||||
if(dirname(work)!==resolve(tmpdir())) throw new Error('Unsafe fixture cleanup path');
|
||||
await rm(work,{recursive:true,force:true});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Real portal + auth hook + API client, synthetic SMS/HTTP responses. Never sends an SMS.
|
||||
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('.design-evidence/customer-sms-login');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:5322,strictPort:true}});
|
||||
await server.listen();
|
||||
let browser;
|
||||
const checks=[],problems=[];
|
||||
try {
|
||||
browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH??'C:/Program Files/Google/Chrome/Application/chrome.exe'});
|
||||
const page=await browser.newPage({viewport:{width:1280,height:1080}});
|
||||
page.on('pageerror',e=>problems.push(e.message));
|
||||
await page.clock.install();
|
||||
let signedIn=false, smsEnabled=true, expireMe=false, sendCount=0, bootstraps=0, lastRequestID, memoryWrites=0;
|
||||
await page.route('**/api/v1/**',async route=>{
|
||||
const r=route.request(), path=new URL(r.url()).pathname, body=r.method()==='POST'?r.postDataJSON():undefined;
|
||||
let status=200,code='OK',data={};
|
||||
if(path==='/api/v1/web/account/session') {
|
||||
if(signedIn)data={authenticated:true,csrf_token:'account-csrf'};else{status=401;code='LOGIN_REQUIRED';}
|
||||
} else if(path==='/api/v1/web/login/session') {bootstraps++;data={sms_enabled:smsEnabled,signup_enabled:true,csrf_token:'login-csrf'};}
|
||||
else if (['/api/v1/web/login/captcha','/api/v1/web/auth/captcha'].includes(path)) { assert.equal(r.headers()['x-csrf-token'],path.includes('/auth/')?'setup-csrf':'login-csrf'); data={captcha_id:'captcha',image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=',expires_at_ms:Date.now()+120000}; }
|
||||
else if(path==='/api/v1/web/login/sms/send') {
|
||||
assert.equal(r.headers()['x-csrf-token'],'login-csrf');assert.equal(r.headers().authorization,undefined);
|
||||
assert.equal(body.captcha_id,'captcha');assert.equal(body.captcha_code,'654321');assert.equal(body.purpose,'LOGIN');assert.match(body.phone_e164,/^\+8613[0-9]{9}$/);
|
||||
sendCount++;lastRequestID=r.headers()['x-request-id'];assert.match(lastRequestID,/^[a-f0-9-]{36}$/);
|
||||
data={sms_challenge_id:`challenge-${sendCount}`,resend_after_ms:60000,expires_at_ms:Date.now()+300000};
|
||||
} else if(path==='/api/v1/web/login/sms/verify') {
|
||||
assert.equal(r.headers()['x-csrf-token'],'login-csrf');assert.equal(body.sms_challenge_id,`challenge-${sendCount}`);
|
||||
if(body.code!=='654321'){status=401;code='SMS_CODE_INVALID';}else{signedIn=true;data={csrf_token:'account-csrf',data:{authenticated:true}};}
|
||||
} else if(path==='/api/v1/me') {
|
||||
assert.equal(r.headers().authorization,undefined);
|
||||
if(!signedIn||expireMe){signedIn=false;status=401;code='LOGIN_REQUIRED';}
|
||||
else data={identity:{issuer:'urn:platform:onboarding:sms',subject:'synthetic',display_name:'短信验收用户',preferred_username:'短信验收用户'},accounts:[]};
|
||||
} else if(path==='/api/v1/memories/query') {
|
||||
assert.equal(r.headers().authorization,undefined);assert.equal(r.headers()['x-csrf-token'],'account-csrf');memoryWrites++;data={items:[]};
|
||||
} else if(path==='/api/v1/content-models'){status=401;code='AUTHENTICATION_REQUIRED';}
|
||||
else if(path==='/api/v1/web/auth/logout'){signedIn=false;data={signed_out:true};}
|
||||
else if(path==='/api/v1/web/account/password') data={available:false};
|
||||
else {throw new Error(`Unexpected API: ${path}`);}
|
||||
await route.fulfill({status,contentType:'application/json',body:JSON.stringify({ok:status===200,code,message:code==='SMS_CODE_INVALID'?'验证码错误或已失效':code,data})});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5322/?section=profile');
|
||||
await page.getByRole('heading',{name:'手机号登录'}).waitFor();
|
||||
await page.waitForFunction(()=>!document.querySelector('#customer-phone').disabled);
|
||||
assert.equal(bootstraps,1);assert.equal(await page.locator('input[type=password]').count(),0);
|
||||
await page.waitForTimeout(400);await page.screenshot({path:resolve(output,'login-desktop.png'),fullPage:true});
|
||||
checks.push('SMS is the default login; StrictMode creates one bootstrap request');
|
||||
assert.equal(new URL(page.url()).pathname,'/login');
|
||||
assert.equal(new URL(page.url()).searchParams.get('returnTo'),'/?section=profile');
|
||||
assert.equal(await page.locator('.customer-layout,.maintenance-layout').count(),0);
|
||||
checks.push('protected page redirects to standalone login with no business navigation');
|
||||
const phone=page.getByLabel('手机号',{exact:true}), otp=page.getByLabel('短信验证码',{exact:true}), form=page.locator('.sms-login-form');
|
||||
assert(await form.getByRole('button',{name:'获取验证码'}).isDisabled());
|
||||
await phone.fill('13800000000');assert(await form.getByRole('button',{name:'获取验证码'}).isDisabled());await page.getByLabel('图片验证码',{exact:true}).fill('654321');await form.getByRole('button',{name:'获取验证码'}).click();
|
||||
await page.getByText(/验证码已发送至 138\*\*\*\*0000/).waitFor();
|
||||
assert.equal(sendCount,1);assert(await form.getByRole('button',{name:/秒后重发/}).isDisabled());
|
||||
checks.push('send uses normalized phone, CSRF, unique request ID and a resend countdown');
|
||||
await otp.fill('654321');await phone.fill('13900000000');
|
||||
assert(await form.getByRole('button',{name:/^登\s*录$/}).isDisabled());
|
||||
checks.push('changing phone clears the old challenge and code');
|
||||
await page.clock.fastForward(61000);await page.getByLabel('图片验证码',{exact:true}).fill('654321');await form.getByRole('button',{name:'获取验证码'}).click();
|
||||
await page.getByText(/验证码已发送至 139\*\*\*\*0000/).waitFor();
|
||||
await otp.fill('000000');await form.getByRole('button',{name:/^登\s*录$/}).click();
|
||||
await page.getByText('验证码错误或已失效',{exact:true}).waitFor();
|
||||
assert.equal(await page.getByRole('heading',{name:'手机号登录'}).count(),1);
|
||||
checks.push('incorrect OTP stays on the login form and does not trigger a global session failure');
|
||||
await page.waitForTimeout(400);await page.screenshot({path:resolve(output,'desktop.png'),fullPage:true});
|
||||
await page.setViewportSize({width:375,height:920});await page.waitForTimeout(400);await page.screenshot({path:resolve(output,'mobile.png'),fullPage:true});
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=document.documentElement.clientWidth+2));
|
||||
checks.push('375px mobile page has no horizontal overflow');
|
||||
await otp.fill('654321');await form.getByRole('button',{name:/^登\s*录$/}).click();
|
||||
await page.getByRole('button',{name:'打开账户菜单'}).waitFor();assert.equal(await page.getByRole('heading',{name:'手机号登录'}).count(),0);
|
||||
assert.equal(new URL(page.url()).search,'?section=profile');
|
||||
await page.reload();await page.getByRole('button',{name:'打开账户菜单'}).waitFor();
|
||||
assert.equal(await page.evaluate(()=>Object.keys(sessionStorage).filter(k=>k.startsWith('oidc.user:')).length),0);
|
||||
checks.push('successful SMS login restores the customer workspace after reload without an OIDC token');
|
||||
await page.evaluate(async()=>{const api=await import('/src/api/client.ts');await api.authenticatedJSON('POST','/api/v1/memories/query','',undefined,{elder_id:'synthetic'});});
|
||||
assert.equal(memoryWrites,1);checks.push('customer API writes send account CSRF with cookie authentication, without an empty Bearer header');
|
||||
await page.evaluate(async()=>{const {platformApi}=await import('/src/api/client.ts');await platformApi.contentModels('synthetic','').catch(()=>{});});
|
||||
await page.waitForTimeout(250);assert.equal(new URL(page.url()).pathname,'/');await page.getByRole('button',{name:'打开账户菜单'}).waitFor();
|
||||
await page.evaluate(async()=>{const {userManager}=await import('/src/auth/oidc.ts');await userManager.events._silentRenewError.raise(new Error('old OIDC error'));});
|
||||
await page.waitForTimeout(100);assert.equal(new URL(page.url()).pathname,'/');
|
||||
checks.push('resource 401 and legacy renewal failure cannot discard a valid phone session');
|
||||
expireMe=true;
|
||||
await page.evaluate(async()=>{const {platformApi}=await import('/src/api/client.ts');await platformApi.currentUser('').catch(()=>{});});
|
||||
await page.getByRole('heading',{name:'手机号登录'}).waitFor();assert.equal(await page.getByText('短信验收用户',{exact:true}).count(),0);
|
||||
assert.equal(new URL(page.url()).searchParams.get('returnTo'),'/?section=profile');
|
||||
checks.push('an expired customer session removes prior private account content');
|
||||
smsEnabled=false;await page.reload();await page.getByText('短信登录暂未开放,请使用密码登录',{exact:true}).waitFor();
|
||||
await phone.fill('13800000000');assert(await form.getByRole('button',{name:'获取验证码'}).isDisabled());
|
||||
checks.push('an unconfigured SMS service is shown as unavailable, never as a successful send');
|
||||
assert.deepEqual(problems,[]);
|
||||
await writeFile(resolve(output,'result.json'),JSON.stringify({checks,problems,boundary:'Synthetic SMS and API responses; no real SMS sent.'},null,2));
|
||||
console.log(JSON.stringify({checks,problems}));
|
||||
} finally {await browser?.close();await server.close();}
|
||||
@@ -0,0 +1,195 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
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 cacheDir = await mkdtemp(join(tmpdir(), 'device-firmware-vite-'));
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('web'), cacheDir, oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error',
|
||||
optimizeDeps: { noDiscovery: true, include: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom', 'react-dom/client', 'antd', '@ant-design/icons', '@phosphor-icons/react', 'oidc-client-ts', 'go-captcha-react'] },
|
||||
server: { host: '127.0.0.1', port: 5330, strictPort: true },
|
||||
plugins: [{
|
||||
name: 'device-firmware-entry-test',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/__device-firmware-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/__device-firmware-module.tsx"></script>');
|
||||
});
|
||||
},
|
||||
resolveId(id) { if (id === '__device-firmware-module.tsx') return '\0__device-firmware-module.tsx'; },
|
||||
load(id) {
|
||||
if (id !== '\0__device-firmware-module.tsx') return;
|
||||
return `import React from 'react';import {createRoot} from 'react-dom/client';
|
||||
import {AuthProvider} from '/src/auth/useAuth.ts';import {CustomerPortal} from '/src/pages/CustomerPortal.tsx';
|
||||
import '/src/styles.css';import '/src/cal-theme.css';import '/src/customer-responsive.css';import '/src/components/home/customer-portal.css';
|
||||
const nativeFetch=window.fetch.bind(window);
|
||||
window.fetch=(input,init)=>{const url=new URL(typeof input==='string'?input:input instanceof URL?input.href:input.url,location.origin);
|
||||
if(url.pathname==='/api/v1/events')return Promise.resolve(new Response(new ReadableStream({start(controller){controller.enqueue(new TextEncoder().encode(': test\\n\\n'));init?.signal?.addEventListener('abort',()=>{try{controller.close()}catch{}})}}),{headers:{'Content-Type':'text/event-stream'}}));
|
||||
return nativeFetch(input,init);};
|
||||
createRoot(document.getElementById('root')).render(React.createElement(AuthProvider,null,React.createElement(CustomerPortal)));`;
|
||||
},
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
|
||||
const checks = [], problems = [];
|
||||
let page;
|
||||
const requests = [];
|
||||
try {
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
|
||||
page.setDefaultTimeout(15000);
|
||||
await page.clock.install();
|
||||
page.on('pageerror', error => problems.push(error.message));
|
||||
const householdID = '10000000-0000-4000-8000-000000000211', deviceID = '10000000-0000-4000-8000-000000000511', subjectID = '10000000-0000-4000-8000-000000000411';
|
||||
const available = () => ({ online: true, current_version: '1.7.0-controls.22', current_version_code: 6, ota_supported: true, update_available: true, latest: { id: 'test-release', model: 'ECHOEAR-S3', version: '1.7.0-controls.23', version_code: 7, release_notes: '在屏幕上显示通话状态。' } });
|
||||
let firmware = available(), firmwareFails = false, historyFails = false, denyOTA = false, canManage = true;
|
||||
let firmwareReads = 0, commandReads = 0, holdNext = false, heldResolve, releaseHeld;
|
||||
const mutations = [];
|
||||
const held = new Promise(resolve => { heldResolve = resolve; });
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname, method = route.request().method();
|
||||
requests.push(`${method} ${path}`);
|
||||
const capabilities = ['household.read', 'household.manage', 'subject.read', 'subject.write', 'conversation.read', 'privacy.read', 'story.review', ...(canManage ? ['device.manage'] : [])];
|
||||
const answer = data => route.fulfill({ json: { ok: true, data } });
|
||||
const fail = message => route.fulfill({ status: 503, json: { ok: false, code: 'SYNTHETIC_FAILURE', message } });
|
||||
if (path.endsWith('/account/session')) return answer({ authenticated: true, csrf_token: 'test-only' });
|
||||
if (path === '/api/v1/me') return answer({ identity: { issuer: 'test-only', subject: 'owner', display_name: '家人' }, accounts: [{ user_id: 'owner', tenant_id: 'tenant', tenant_name: '安心陪伴', platform_role: 'customer', account_kind: 'customer', household_memberships: [{ household_id: householdID, household_name: '我们的家', role: canManage ? 'owner' : 'viewer', capabilities }] }] });
|
||||
if (path.endsWith('/households')) return answer({ items: [{ id: householdID, name: '我们的家', status: 'active', role: canManage ? 'owner' : 'viewer', capabilities }] });
|
||||
if (path.endsWith('/subjects')) return answer({ items: [{ id: subjectID, household_id: householdID, preferred_name: '奶奶', timezone: 'Asia/Shanghai', language: 'zh-CN', status: 'active', profile_data: {}, conversation_preferences: {} }] });
|
||||
if (path.endsWith('/devices')) return answer({ items: [{ id: deviceID, household_id: householdID, subject_id: subjectID, subject_name: '奶奶', model: 'ECHOEAR-S3', status: 'active', desired_revision: 1, reported_revision: 1 }] });
|
||||
if (path.endsWith('/presence')) return answer({ device_id: deviceID, online: true, status: 'online', source: 'mqtt' });
|
||||
if (path.endsWith('/twin')) return answer({ device_id: deviceID, desired_config: { volume: { speaker: 60 } }, reported_config: { volume: { speaker: 60 } }, desired_revision: 1, reported_revision: 1, sync_state: 'applied', capabilities: {} });
|
||||
if (path.endsWith('/readiness') || path.endsWith('/conversation-readiness')) return answer({ subject_id: subjectID, ready: true, checks: [], devices: [], start_time_checks: [] });
|
||||
if (path.endsWith('/firmware')) {
|
||||
firmwareReads += 1;
|
||||
const data = structuredClone(firmware);
|
||||
if (holdNext) { holdNext = false; heldResolve(); await new Promise(resolve => { releaseHeld = resolve; }); }
|
||||
return firmwareFails ? fail('固件服务暂时无法读取') : answer(data);
|
||||
}
|
||||
if (path.endsWith('/commands') && method === 'GET') { commandReads += 1; return historyFails ? fail('命令历史暂时无法读取') : answer({ items: [] }); }
|
||||
if (path.endsWith('/ota') && method === 'POST') {
|
||||
mutations.push({ path, body: route.request().postDataJSON() });
|
||||
if (denyOTA) return fail('设备正在通话,请稍后重试。');
|
||||
firmware.ota_task = { task_id: 'test-ota', status: 'pending', version: firmware.latest.version, version_code: firmware.latest.version_code };
|
||||
return answer(firmware.ota_task);
|
||||
}
|
||||
if (method !== 'GET') throw new Error(`unexpected test mutation ${method} ${path}`);
|
||||
return answer({ items: [] });
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5330/__device-firmware-test?section=devices', { waitUntil: 'domcontentloaded' });
|
||||
const panel = page.getByRole('region', { name: '固件升级', exact: true });
|
||||
const update = () => panel.getByRole('button', { name: '更新固件', exact: true });
|
||||
const refresh = () => panel.getByRole('button', { name: '检查更新', exact: true }).click();
|
||||
const confirm = () => panel.getByRole('group', { name: '确认设备操作' }).getByRole('button', { name: '确认', exact: true }).click();
|
||||
await panel.getByText('1.7.0-controls.23', { exact: true }).waitFor();
|
||||
await server.waitForRequestsIdle();
|
||||
assert.equal(firmwareReads, 1);
|
||||
assert.equal(commandReads, 1);
|
||||
assert(await update().isEnabled());
|
||||
await page.getByRole('button', { name: '固件升级', exact: true }).click();
|
||||
assert(await panel.evaluate(element => element === document.activeElement));
|
||||
const cardLayout = await page.evaluate(() => ({ firmware: document.querySelector('.device-controls').getBoundingClientRect().top, settings: [...document.querySelectorAll('.cp-setting-row')].find(element => element.textContent.includes('音量与设置')).getBoundingClientRect().top }));
|
||||
assert(cardLayout.firmware < cardLayout.settings);
|
||||
await page.getByRole('button', { name: '查看设备详情', exact: true }).click();
|
||||
await page.locator('.cp-device-detail').getByRole('button', { name: /固件升级/ }).click();
|
||||
assert.equal(await page.locator('.cp-device-detail').count(), 0);
|
||||
assert(await panel.evaluate(element => element === document.activeElement));
|
||||
assert.equal(firmwareReads, 1, 'card and detail must reuse one firmware component');
|
||||
checks.push('actual CustomerPortal device card and detail expose firmware upgrade and reuse one authoritative GET');
|
||||
|
||||
historyFails = true; await refresh();
|
||||
await panel.getByText('1.7.0-controls.23', { exact: true }).waitFor();
|
||||
await page.waitForFunction(() => document.querySelector('.device-controls button')?.textContent === '检查更新');
|
||||
assert(await update().isEnabled());
|
||||
await panel.getByText('远程控制与最近操作', { exact: true }).click();
|
||||
await panel.getByRole('alert').filter({ hasText: '最近操作读取失败' }).waitFor();
|
||||
checks.push('command-history failure does not hide firmware status or disable an available update');
|
||||
|
||||
firmwareFails = true; await refresh();
|
||||
await panel.getByRole('alert').filter({ hasText: '固件服务暂时无法读取' }).waitFor();
|
||||
assert.equal(await panel.getByText('1.7.0-controls.23', { exact: true }).count(), 0);
|
||||
assert.equal(await panel.getByText('暂无可更新的版本。', { exact: true }).count(), 0);
|
||||
assert(await update().isDisabled());
|
||||
firmwareFails = false; historyFails = false; await refresh();
|
||||
await panel.getByText('1.7.0-controls.23', { exact: true }).waitFor();
|
||||
checks.push('firmware read failure clears stale versions, disables mutation, and recovers without claiming latest');
|
||||
|
||||
firmware.online = false; await refresh();
|
||||
await panel.getByText('设备离线,联网后可更新或重启。', { exact: true }).waitFor();
|
||||
assert(await update().isDisabled());
|
||||
firmware.online = true; firmware.ota_supported = false; await refresh();
|
||||
await panel.getByText('当前固件尚未支持在线升级。', { exact: true }).waitFor();
|
||||
assert(await update().isDisabled());
|
||||
firmware = available(); await refresh();
|
||||
await panel.getByText('有新版本可更新。升级时请保持通电,通话结束后再操作。', { exact: true }).waitFor();
|
||||
checks.push('offline and unsupported states remain accurate and block upgrade');
|
||||
|
||||
await update().click();
|
||||
firmware.latest = { ...firmware.latest, version: '1.7.0-controls.24', version_code: 8 };
|
||||
await refresh();
|
||||
await panel.getByText('1.7.0-controls.24', { exact: true }).waitFor();
|
||||
await confirm();
|
||||
await panel.getByText('可升级版本或设备状态已变化,请重新检查并选择升级。', { exact: true }).waitFor();
|
||||
assert.deepEqual(mutations, []);
|
||||
firmware = available(); await refresh();
|
||||
await panel.getByText('1.7.0-controls.23', { exact: true }).waitFor();
|
||||
denyOTA = true; await update().click(); await confirm();
|
||||
await panel.getByText('设备正在通话,请稍后重试。', { exact: true }).waitFor();
|
||||
assert.equal(mutations.length, 1);
|
||||
assert.equal(await panel.getByText('升级请求已发送,请保持设备通电和联网。', { exact: true }).count(), 0);
|
||||
denyOTA = false; await confirm();
|
||||
await panel.getByText('升级请求已发送,请保持设备通电和联网。', { exact: true }).waitFor();
|
||||
await panel.getByText('升级状态:等待设备接收', { exact: true }).waitFor();
|
||||
assert.deepEqual(mutations.map(item => item.body), [{ version_code: 7 }, { version_code: 7 }]);
|
||||
assert(mutations.every(item => item.path === `/api/v1/devices/${deviceID}/ota`));
|
||||
checks.push('confirmation locks version_code, blocks changed release, reports backend busy, and posts only the selected real OTA contract');
|
||||
|
||||
firmware.ota_task.status = 'accepted'; await page.clock.runFor(10050);
|
||||
await panel.getByText('升级状态:设备正在升级', { exact: true }).waitFor();
|
||||
assert(await panel.getByRole('button', { name: '升级进行中', exact: true }).isDisabled());
|
||||
firmware.current_version = firmware.latest.version; firmware.current_version_code = 7; firmware.update_available = false; firmware.ota_task.status = 'success';
|
||||
await page.clock.runFor(10050);
|
||||
await panel.getByText('升级状态:升级完成', { exact: true }).waitFor();
|
||||
assert.equal(await panel.getByText('1.7.0-controls.23', { exact: true }).count(), 2);
|
||||
assert(await update().isDisabled());
|
||||
checks.push('10-second polling follows accepted/success and reported current version without optimistic completion');
|
||||
|
||||
holdNext = true; const beforeHold = firmwareReads;
|
||||
await refresh(); await held;
|
||||
assert(await panel.getByRole('button', { name: '正在检查…', exact: true }).isDisabled());
|
||||
await page.clock.runFor(10050);
|
||||
assert.equal(firmwareReads, beforeHold + 1);
|
||||
releaseHeld(); await panel.getByRole('button', { name: '检查更新', exact: true }).waitFor();
|
||||
checks.push('manual refresh and periodic poll cannot overlap');
|
||||
|
||||
const evidence = process.env.DEVICE_FIRMWARE_OUTPUT_DIR;
|
||||
if (evidence) { await mkdir(evidence, { recursive: true }); await page.screenshot({ path: resolve(evidence, 'firmware-desktop.png'), fullPage: true }); }
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.getByRole('button', { name: '固件升级', exact: true }).click();
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) < 3);
|
||||
assert(await panel.getByRole('button', { name: '检查更新', exact: true }).isVisible());
|
||||
if (evidence) await page.screenshot({ path: resolve(evidence, 'firmware-mobile.png'), fullPage: true });
|
||||
checks.push('mobile upgrade entry and version facts are visible without horizontal overflow');
|
||||
|
||||
canManage = false; const beforeViewerCommands = commandReads;
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await panel.getByText('此账号可查看版本,升级请联系家庭管理员。', { exact: true }).waitFor();
|
||||
assert.equal(await update().count(), 0);
|
||||
assert.equal(commandReads, beforeViewerCommands);
|
||||
assert.equal(mutations.length, 2);
|
||||
checks.push('viewer can read versions but has no update/reboot controls and sends no command-history request');
|
||||
assert.deepEqual(problems, []);
|
||||
console.log(JSON.stringify({ checks, problems }, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ checks, problems, requests, body: await page?.locator('body').innerText() }, null, 2));
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close(); await server.close();
|
||||
if (dirname(cacheDir) !== resolve(tmpdir()) || !basename(cacheDir).startsWith('device-firmware-vite-')) throw new Error('unexpected test cache path');
|
||||
await rm(cacheDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Real CallHistory component with synthetic responses; no production requests.
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
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 session='11111111-1111-4111-8111-111111111111';
|
||||
const server=await createServer({configFile:false,root:resolve('web'),oxc:{jsx:{runtime:'automatic'}},logLevel:'error',server:{host:'127.0.0.1',port:5337,strictPort:true},plugins:[{
|
||||
name:'direct-story-harness',
|
||||
configureServer(s){s.middlewares.use('/__story-history',(_q,r)=>{r.setHeader('Content-Type','text/html');r.end('<!doctype html><meta charset="UTF-8"><div id="root"></div><script type="module" src="/@id/__story-history-module"></script>');});},
|
||||
resolveId(id){if(id==='__story-history-module')return '\0__story-history-module';},
|
||||
load(id){if(id==='\0__story-history-module')return `import React from 'react';import {createRoot} from 'react-dom/client';import {CallHistory} from '/src/pages/CallHistory.tsx';createRoot(document.getElementById('root')).render(React.createElement(CallHistory,{elderID:'synthetic-elder',accessToken:'synthetic-token',initialSessionID:'${session}'}));`;}
|
||||
}]});
|
||||
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 page=await browser.newPage();
|
||||
const errors=[],requested=[];
|
||||
let denied=false;
|
||||
page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/api/v1/memories/**',async route=>{
|
||||
const req=route.request(),op=new URL(req.url()).pathname.split('/memories/')[1],body=req.postDataJSON();
|
||||
let data={},status=200,code='OK';
|
||||
if(op==='calls/query') data={items:[],next_cursor:'older-calls'};
|
||||
else if(op==='calls/recordings') data={items:[],state:'NOT_CONFIGURED',can_play:false,can_download:false};
|
||||
else if(op==='calls/turns') {
|
||||
requested.push(body); assert.equal(body.business_session_id,session); assert.equal(body.elder_id,'synthetic-elder');
|
||||
if(denied) {status=403;code='TRANSCRIPT_ACCESS_DENIED';}
|
||||
else data={items:[{id:body.cursor?'u2':'u1',speaker:'USER',final_text:body.cursor?'后来学校搬到了山脚。':'小时候我在河边小学读书。',interrupted:false}],next_cursor:body.cursor?'':'second-page'};
|
||||
} else throw new Error(`Unexpected endpoint ${op}`);
|
||||
await route.fulfill({status,contentType:'application/json',body:JSON.stringify({ok:status===200,code,message:code,data,request_id:'synthetic',server_time_ms:Date.now()})});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5337/__story-history');
|
||||
await page.getByText('小时候我在河边小学读书。',{exact:true}).waitFor();
|
||||
assert.equal(await page.getByRole('heading',{name:'故事的原始通话'}).count(),1);
|
||||
await page.getByRole('button',{name:'加载更多对话',exact:true}).click();
|
||||
await page.getByText('后来学校搬到了山脚。',{exact:true}).waitFor();
|
||||
assert.equal(requested.at(-1).cursor,'second-page');
|
||||
await page.getByRole('button',{name:'刷新记录',exact:true}).click();
|
||||
await page.getByText('小时候我在河边小学读书。',{exact:true}).waitFor();
|
||||
assert.equal(requested.at(-1).cursor,undefined);
|
||||
denied=true;
|
||||
await page.getByRole('button',{name:'刷新记录',exact:true}).click();
|
||||
await page.getByRole('alert').filter({hasText:'当前账号没有查看聊天文字的权限,请联系家庭所有者授权。'}).waitFor();
|
||||
assert.equal(await page.getByText('小时候我在河边小学读书。',{exact:true}).count(),0);
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: story source outside first page; source pagination; refresh retains call; denied access clears text');
|
||||
} finally {await browser?.close();await server.close();}
|
||||
@@ -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(); }
|
||||
@@ -0,0 +1,180 @@
|
||||
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('<!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-module.tsx"></script>'); }); },
|
||||
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(); }
|
||||
@@ -0,0 +1,164 @@
|
||||
// Real portal routing and dialogs with synthetic account data. All writes and
|
||||
// external requests are blocked; supported Bluetooth is a non-operating stub.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { installCustomerFixture, devices, 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 work = await mkdtemp(join(tmpdir(), 'story-family-device-guidance-'));
|
||||
const output = resolve(process.env.UI_GUIDANCE_OUTPUT ?? '../.local-evidence/family-device-guidance-20261006');
|
||||
await mkdir(join(work, 'empty-env')); await mkdir(output, { recursive: true });
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('web'), envDir: join(work, 'empty-env'), cacheDir: join(work, 'vite-cache'),
|
||||
oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 },
|
||||
plugins: [{
|
||||
name: 'family-device-guidance-fixture',
|
||||
configureServer(vite) { vite.middlewares.use('/__guidance', (_q, response) => {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.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/__guidance.tsx"></script>');
|
||||
}); },
|
||||
resolveId(name) { if (name === '__guidance.tsx') return '\0__guidance.tsx'; },
|
||||
load(name) { if (name === '\0__guidance.tsx') return `
|
||||
import React from 'react'; import {createRoot} from 'react-dom/client';
|
||||
import {AuthProvider} from '/src/auth/useAuth.ts'; import {CustomerPortal} from '/src/pages/CustomerPortal.tsx';
|
||||
import '/src/styles.css'; import '/src/cal-theme.css'; import '/src/customer-responsive.css';
|
||||
createRoot(document.getElementById('root')).render(React.createElement(AuthProvider,null,React.createElement(CustomerPortal)));
|
||||
`; },
|
||||
}],
|
||||
});
|
||||
const errors = [], externalRequests = [], mutations = [], checks = [], failures = [];
|
||||
let browser, origin;
|
||||
async function run(name, options, test) {
|
||||
const context = await browser.newContext({ viewport: options.viewport, serviceWorkers: 'block' });
|
||||
const unexpected = await installCustomerFixture(context);
|
||||
await context.addInitScript(supported => {
|
||||
window.bluetoothRequests = 0;
|
||||
Object.defineProperty(navigator, 'bluetooth', { configurable: true, value: supported ? {
|
||||
requestDevice() { window.bluetoothRequests++; throw new Error('Synthetic Bluetooth must not be invoked'); },
|
||||
} : undefined });
|
||||
}, !!options.bluetooth);
|
||||
const page = await context.newPage(); page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.route('**/*', async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname;
|
||||
if (url.origin !== origin) { externalRequests.push(url.origin + path); return route.abort(); }
|
||||
if (!path.startsWith('/api/')) return route.continue();
|
||||
if (request.method() !== 'GET') { mutations.push(`${request.method()} ${path}`); return route.abort(); }
|
||||
const answer = data => route.fulfill({ json: { ok: true, data, request_id: 'synthetic-guidance' } });
|
||||
if (path.endsWith('/events/history') || path.endsWith('/firmware')) return answer({ items: [] });
|
||||
if (path.endsWith('/home-overview')) return answer({ subject_id: id(400), household_id: id(200), timezone: 'Asia/Shanghai', week_start: '2026-10-05', week_end: '2026-10-12', daily: Array.from({ length: 7 }, (_, index) => ({ date: '2026-10-' + String(index + 5).padStart(2, '0'), conversation_count: 0, duration_ms: 0 })), conversation_count: 0, total_duration_ms: 0, latest_conversation: null });
|
||||
if (path === '/api/v1/devices') return answer({ items: options.owned ? [{ ...devices[0], account_binding_id: id(991), household_id: undefined, subject_id: undefined, subject_name: '' }] : [] });
|
||||
if (path.endsWith('/conversation-readiness')) return answer({ subject_id: id(400), ready: false, devices: [], evaluated_at: new Date().toISOString(), start_time_checks: [], checks: [
|
||||
{ code: 'DEVICE_BOUND', status: 'blocked', blocking: true, title: '尚未绑定设备', detail: '请使用设备屏幕显示的一次性绑定码完成绑定。', action: 'BIND_DEVICE' },
|
||||
] });
|
||||
return route.fallback();
|
||||
});
|
||||
try {
|
||||
await page.goto(`${origin}/__guidance${options.section ? '?section=' + options.section : ''}`);
|
||||
await test(page);
|
||||
assert.deepEqual(unexpected, []); assert.equal(await page.evaluate(() => window.bluetoothRequests), 0);
|
||||
checks.push(name);
|
||||
} catch (error) {
|
||||
failures.push({ name, message: error.message });
|
||||
await page.screenshot({ path: resolve(output, `${name}-failure.png`) });
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
try {
|
||||
await server.listen(); origin = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
browser = await chromium.launch({ headless: true, ...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}) });
|
||||
for (const viewport of [{ width: 1188, height: 761 }, { width: 389, height: 844 }]) {
|
||||
await run(`rename-${viewport.width}`, { viewport, section: 'members' }, async page => {
|
||||
const opener = page.getByRole('button', { name: /编辑名称/ });
|
||||
await opener.click(); const dialog = page.locator('dialog.household-name-dialog'); await dialog.waitFor();
|
||||
const input = dialog.getByRole('textbox'), original = await input.inputValue();
|
||||
await input.fill('未保存的合成家庭草稿');
|
||||
const cancel = dialog.getByRole('button', { name: '取消', exact: true });
|
||||
const save = dialog.getByRole('button', { name: '保存', exact: true });
|
||||
const close = dialog.getByRole('button', { name: '关闭编辑家庭', exact: true });
|
||||
assert(await save.isEnabled());
|
||||
const metrics = await dialog.evaluate(element => {
|
||||
const rect = node => { const r = node.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right }; };
|
||||
const footer = element.querySelector('footer'), buttons = [...footer.querySelectorAll('button')];
|
||||
return { dialog: rect(element), header: rect(element.querySelector('header')), title: rect(element.querySelector('h3')), close: rect(element.querySelector('header button')),
|
||||
gap: rect(buttons[1]).x - rect(buttons[0]).right, footerDisplay: getComputedStyle(footer).display,
|
||||
buttons: buttons.map(node => ({ ...rect(node), background: getComputedStyle(node).backgroundColor, padding: getComputedStyle(node).padding })) };
|
||||
});
|
||||
await writeFile(resolve(output, `rename-${viewport.width}-metrics.json`), JSON.stringify(metrics, null, 2));
|
||||
await page.screenshot({ path: resolve(output, `rename-${viewport.width}.png`) });
|
||||
assert(metrics.gap >= 10, 'cancel and save need visible spacing');
|
||||
assert(metrics.buttons.every(button => button.width >= 44 && button.height >= 44), 'actions need touch targets');
|
||||
assert(metrics.close.width >= 44 && metrics.close.height >= 44, 'close needs a touch target');
|
||||
assert(metrics.close.x > metrics.title.right, 'close must be separate from the title');
|
||||
assert.notEqual(metrics.buttons[0].background, metrics.buttons[1].background, 'primary and secondary actions differ');
|
||||
assert.notEqual(metrics.buttons[1].background, 'rgba(0, 0, 0, 0)');
|
||||
assert(metrics.dialog.x >= 0 && metrics.dialog.right <= viewport.width + 1);
|
||||
await save.focus(); await page.keyboard.press('Tab'); assert(await close.evaluate(el => el === document.activeElement));
|
||||
await page.keyboard.press('Shift+Tab'); assert(await save.evaluate(el => el === document.activeElement));
|
||||
await cancel.click(); await dialog.waitFor({ state: 'detached' });
|
||||
assert(await opener.evaluate(el => el === document.activeElement));
|
||||
await opener.click(); await dialog.waitFor(); assert.equal(await input.inputValue(), original);
|
||||
await input.fill('另一份未保存草稿'); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' });
|
||||
assert(await opener.evaluate(el => el === document.activeElement)); assert.deepEqual(mutations, []);
|
||||
});
|
||||
for (const bluetooth of [false, true]) await run(`add-${viewport.width}-${bluetooth}`, { viewport, bluetooth }, async page => {
|
||||
const preparation = page.locator('.ch-preparation');
|
||||
await preparation.getByRole('button', { name: '绑定陪伴设备', exact: true }).waitFor();
|
||||
assert.doesNotMatch(await preparation.innerText(), /一次性绑定码/);
|
||||
assert.match(await preparation.innerText(), /蓝牙/);
|
||||
const action = preparation.getByRole('button', { name: '绑定陪伴设备', exact: true });
|
||||
await action.focus(); await page.keyboard.press('Enter');
|
||||
await page.waitForURL(url => url.searchParams.get('section') === 'devices');
|
||||
await page.getByRole('heading', { name: '陪伴设备', exact: true }).waitFor();
|
||||
const dialog = page.getByRole('dialog', { name: '添加陪伴设备', exact: true });
|
||||
assert.equal(await dialog.count(), 1, 'home bind action opens the existing add flow directly');
|
||||
await dialog.waitFor();
|
||||
assert.equal(await dialog.getByRole('list', { name: '添加步骤' }).locator('li').count(), 3);
|
||||
if (bluetooth) assert(await dialog.getByRole('button', { name: '搜索附近设备', exact: true }).isEnabled());
|
||||
else {
|
||||
const notice = dialog.getByRole('alert');
|
||||
assert.match(await notice.innerText(), /HTTPS/); assert.match(await notice.innerText(), /同一账号/);
|
||||
assert.match(await notice.innerText(), /添加设备/);
|
||||
assert.match(await notice.innerText(), /Chrome/); assert.doesNotMatch(await notice.innerText(), /小程序|绑定码/);
|
||||
assert.equal(await dialog.getByRole('button', { name: '搜索附近设备', exact: true }).count(), 0);
|
||||
assert.equal(await dialog.getByRole('status').count(), 0, 'unsupported browsers do not ask the user to search here');
|
||||
}
|
||||
await page.screenshot({ path: resolve(output, `add-${viewport.width}-${bluetooth}.png`) });
|
||||
assert(await dialog.evaluate(el => el.contains(document.activeElement)));
|
||||
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' });
|
||||
await page.goBack(); await preparation.waitFor();
|
||||
await preparation.locator('summary').click();
|
||||
await preparation.getByRole('button', { name: /前往处理/ }).click();
|
||||
await dialog.waitFor();
|
||||
await page.goBack(); await preparation.waitFor();
|
||||
if (viewport.width < 900) await page.getByRole('button', { name: '打开导航菜单', exact: true }).click();
|
||||
await page.getByRole('link', { name: '陪伴设备', exact: true }).click();
|
||||
await page.waitForURL(url => url.searchParams.get('section') === 'devices');
|
||||
assert.equal(await dialog.count(), 0, 'ordinary device navigation does not auto-open setup');
|
||||
});
|
||||
await run(`existing-${viewport.width}`, { viewport, owned: true }, async page => {
|
||||
await page.locator('.ch-preparation').getByRole('button', { name: '绑定陪伴设备', exact: true }).click();
|
||||
await page.waitForURL(url => url.searchParams.get('section') === 'devices');
|
||||
await page.getByRole('button', { name: '绑定老人', exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole('dialog', { name: '添加陪伴设备', exact: true }).count(), 0, 'an already added device stays available for assignment');
|
||||
await page.getByRole('button', { name: /^(更换 Wi-Fi|重新连接)$/ }).click();
|
||||
const notice = page.getByRole('dialog').getByRole('alert');
|
||||
assert.match(await notice.innerText(), /找到该设备并继续当前操作/);
|
||||
assert.doesNotMatch(await notice.innerText(), /添加设备/);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
}
|
||||
assert.deepEqual(errors, []); assert.deepEqual(externalRequests, []); assert.deepEqual(mutations, []);
|
||||
const result = { passed: !failures.length, checks, failures, browserErrors: errors, externalRequests, mutations };
|
||||
await writeFile(resolve(output, 'checks.json'), JSON.stringify(result, null, 2)); console.log(JSON.stringify(result));
|
||||
assert.deepEqual(failures, []);
|
||||
} finally {
|
||||
await browser?.close(); await server.close();
|
||||
assert(resolve(work).startsWith(resolve(tmpdir()) + (process.platform === 'win32' ? '\\' : '/')));
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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-home-drawer/evidence');
|
||||
await mkdir(resolve(output, 'empty-env'), { recursive: true });
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('web'), envDir: resolve(output, 'empty-env'), cacheDir: resolve(output, 'vite-cache'),
|
||||
oxc: { jsx: { runtime: 'automatic' } }, optimizeDeps: { entries: ['src/components/home/CustomerHomePage.tsx'] }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 },
|
||||
plugins: [{
|
||||
name: 'home-drawer-regression',
|
||||
configureServer(server) { server.middlewares.use('/__drawer-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/__drawer-module.tsx"></script>');
|
||||
}); },
|
||||
resolveId(id) { if (id === '__drawer-module.tsx') return '\0__drawer-module.tsx'; },
|
||||
load(id) { if (id === '\0__drawer-module.tsx') return `
|
||||
import React from 'react'; import {createRoot} from 'react-dom/client';
|
||||
import {CustomerHomePage} from '/src/components/home/CustomerHomePage.tsx';
|
||||
import '/src/styles.css'; import '/src/cal-theme.css'; import '/src/customer-responsive.css'; import '/src/components/home/customer-portal.css';
|
||||
function App(){ const [section,setSection]=React.useState('home'); const [household,setHousehold]=React.useState('A');
|
||||
return React.createElement(CustomerHomePage,{households:[{id:'A',name:'合成家庭 A'},{id:'B',name:'合成家庭 B'}],householdID:household,
|
||||
subjects:[],devices:[],presence:{},conversations:[],insights:{},stories:[],privacyRequests:[],
|
||||
activeSection:section,onHousehold:setHousehold,onSubject:()=>{},onNavigate:setSection,onRetry:()=>{},onLogout:()=>{},
|
||||
children:section==='home'?undefined:React.createElement(React.Fragment,null,React.createElement('h1',{tabIndex:-1},'合成页面:'+section),React.createElement('p',null,'当前家庭:'+household))});
|
||||
}createRoot(document.getElementById('root')).render(React.createElement(App));`; },
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
const launchOptions = { headless: true, ...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}) };
|
||||
const nativeZoom = process.env.QA_NATIVE_ZOOM === '200';
|
||||
const browser = nativeZoom ? undefined : await chromium.launch(launchOptions);
|
||||
const checks=[], errors=[], blocked=[];
|
||||
const context=nativeZoom
|
||||
? await chromium.launchPersistentContext(resolve(output,'chrome-profile'),{...launchOptions,viewport:null,serviceWorkers:'block',args:['--window-size=1188,855']})
|
||||
: await browser.newContext({viewport:{width:586,height:380}, serviceWorkers:'block'});
|
||||
if(nativeZoom) {
|
||||
// Chrome's own zoom preference in a disposable test profile, not CSS zoom,
|
||||
// pinch scaling or an emulated device scale factor.
|
||||
const settings=await context.newPage(); await settings.goto('chrome://settings/appearance');
|
||||
assert.equal(await settings.evaluate(()=>chrome.settingsPrivate.setDefaultZoom(2)),true);
|
||||
assert.equal(await settings.evaluate(()=>chrome.settingsPrivate.getDefaultZoom()),2);
|
||||
await settings.close();
|
||||
checks.push('Isolated Chrome profile uses native 200% page zoom');
|
||||
}
|
||||
const page=await context.newPage();
|
||||
page.setDefaultTimeout(60000);
|
||||
page.on('pageerror', error=>errors.push(error.message));
|
||||
page.on('console', message=>{if(message.type()==='error')console.error(message.text());});
|
||||
await page.route('**/*', route=>new URL(route.request().url()).origin===origin?route.continue():(blocked.push(route.request().url()),route.abort()));
|
||||
const metrics=()=>page.evaluate(()=>{
|
||||
const drawer=document.querySelector('.ch-mobile-drawer .ant-drawer-body'), active=document.activeElement, rect=active?.getBoundingClientRect();
|
||||
return {viewport:{width:innerWidth,height:innerHeight},devicePixelRatio,backgroundY:scrollY,documentHeight:document.documentElement.scrollHeight,
|
||||
horizontalOverflow:document.documentElement.scrollWidth>innerWidth,bodyOverflow:getComputedStyle(document.body).overflow,
|
||||
active:active?.textContent,activeClass:active?.className,rect:rect&&{top:rect.top,bottom:rect.bottom},
|
||||
drawer:drawer&&{clientHeight:drawer.clientHeight,scrollHeight:drawer.scrollHeight,scrollTop:drawer.scrollTop,overflowY:getComputedStyle(drawer).overflowY}};
|
||||
});
|
||||
try {
|
||||
await page.goto(`${origin}/__drawer-test`);
|
||||
const toggle=page.getByRole('button',{name:'打开导航菜单',exact:true}); await toggle.waitFor();
|
||||
page.setDefaultTimeout(15000);
|
||||
assert.deepEqual((await metrics()).viewport,{width:586,height:380});
|
||||
if(nativeZoom) assert.equal((await metrics()).devicePixelRatio,2);
|
||||
assert.equal((await metrics()).horizontalOverflow,false);
|
||||
await toggle.click();
|
||||
const drawer=page.locator('.ch-mobile-drawer'); await drawer.locator('.ant-drawer-close').waitFor();
|
||||
// The reported path: first Tab reaches Close, Shift+Tab wraps to the last link.
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await drawer.locator('.ant-drawer-close').evaluate(el=>el===document.activeElement),true,'First Tab reaches the close button');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await page.waitForTimeout(150);
|
||||
const first=await metrics();
|
||||
await writeFile(resolve(output,'reported-path.json'),JSON.stringify(first,null,2));
|
||||
await page.screenshot({path:resolve(output,'reported-path-586x380.png')});
|
||||
if(process.env.QA_BASELINE==='1') { console.log(JSON.stringify(first)); }
|
||||
else {
|
||||
assert.match(first.active,/设置与权限/);
|
||||
assert.ok(first.rect.top>=0&&first.rect.bottom<=380,`Last link must be visible: ${JSON.stringify(first)}`);
|
||||
assert.ok(first.drawer.clientHeight<first.drawer.scrollHeight,'Drawer body must be independently scrollable');
|
||||
assert.ok(first.drawer.scrollTop>0,'Keyboard wrap must scroll inside the drawer');
|
||||
assert.equal(first.backgroundY,0,'Background cannot scroll on focus wrap');
|
||||
checks.push('586x380: Shift+Tab from Close reaches visible Settings inside the scrolled drawer');
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await drawer.locator('.ant-drawer-close').evaluate(el=>el===document.activeElement),true);
|
||||
for(let i=0;i<20;i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
const m=await metrics(); assert.ok(m.rect.top>=0&&m.rect.bottom<=380,`Focus must be visible ${i}: ${JSON.stringify(m)}`);
|
||||
assert.equal(m.backgroundY,0); assert.equal(await drawer.evaluate(el=>el.contains(document.activeElement)),true);
|
||||
}
|
||||
checks.push('20 forward tabs stay visible inside drawer without moving background');
|
||||
for(let i=0;i<15;i++) {
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
const m=await metrics(); assert.ok(m.rect.top>=0&&m.rect.bottom<=380); assert.equal(m.backgroundY,0);
|
||||
assert.equal(await drawer.evaluate(el=>el.contains(document.activeElement)),true);
|
||||
}
|
||||
checks.push('15 reverse tabs stay visible inside drawer without moving background');
|
||||
await drawer.locator('.ant-drawer-close').focus();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await page.mouse.move(180,340); await page.mouse.wheel(0,1200); await page.waitForTimeout(150);
|
||||
assert.equal((await metrics()).backgroundY,0); checks.push('Wheel at drawer bottom does not scroll background');
|
||||
for(const close of ['Escape','button','mask']) {
|
||||
if(close==='Escape') await page.keyboard.press('Escape');
|
||||
else if(close==='button') await drawer.locator('.ant-drawer-close').click();
|
||||
else await drawer.locator('.ant-drawer-mask').click({position:{x:550,y:50}});
|
||||
await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')==='打开导航菜单');
|
||||
assert.equal((await metrics()).backgroundY,0);
|
||||
await toggle.click(); await drawer.locator('.ant-drawer-close').waitFor();
|
||||
}
|
||||
checks.push('Repeated Escape, Close and mask cancellation restore menu-trigger focus');
|
||||
await page.keyboard.press('Escape'); await drawer.locator('.ant-drawer-content-wrapper').waitFor({state:'hidden'});
|
||||
await page.evaluate(()=>window.scrollTo(0,180)); await toggle.click();
|
||||
await drawer.locator('.ant-drawer-close').focus(); await page.keyboard.press('Shift+Tab');
|
||||
assert.equal((await metrics()).backgroundY,180);
|
||||
await page.mouse.move(500,300); await page.mouse.wheel(0,1200); await page.waitForTimeout(150);
|
||||
assert.equal((await metrics()).backgroundY,180);
|
||||
await page.keyboard.press('Escape'); await drawer.locator('.ant-drawer-content-wrapper').waitFor({state:'hidden'});
|
||||
assert.equal((await metrics()).backgroundY,180);
|
||||
await page.evaluate(()=>window.scrollTo(0,0)); await toggle.click();
|
||||
checks.push('Opening from a scrolled page locks and restores its prior scroll position, including mask wheel input');
|
||||
await drawer.getByRole('link',{name:'设置与权限',exact:true}).click();
|
||||
await drawer.locator('.ant-drawer-content-wrapper').waitFor({state:'hidden'});
|
||||
await page.getByRole('heading',{name:'合成页面:profile'}).waitFor();
|
||||
assert.equal(await page.getByRole('heading',{name:'合成页面:profile'}).evaluate(el=>el===document.activeElement),true);
|
||||
checks.push('Navigation closes drawer and focuses new page heading');
|
||||
await toggle.click(); await drawer.getByRole('button',{name:'切换家庭',exact:true}).click();
|
||||
await page.getByRole('menuitem',{name:'合成家庭 B',exact:true}).click();
|
||||
await page.getByRole('menu').waitFor({state:'hidden'});
|
||||
await page.keyboard.press('Escape');
|
||||
await drawer.locator('.ant-drawer-content-wrapper').waitFor({state:'hidden'});
|
||||
assert.match(await page.locator('.ch-main').innerText(),/当前家庭:B/);
|
||||
assert.doesNotMatch(await page.locator('.ch-main').innerText(),/当前家庭:A/);
|
||||
checks.push('Household dropdown remains usable and selected family stays isolated');
|
||||
if(!nativeZoom) {
|
||||
await page.setViewportSize({width:390,height:844}); await toggle.click();
|
||||
await drawer.locator('.ant-drawer-close').focus(); await page.keyboard.press('Shift+Tab');
|
||||
const mobile=await metrics(); assert.ok(mobile.rect.top>=0&&mobile.rect.bottom<=844); assert.equal(mobile.horizontalOverflow,false);
|
||||
await page.screenshot({path:resolve(output,'drawer-390x844.png')});
|
||||
await page.keyboard.press('Escape'); await drawer.locator('.ant-drawer-content-wrapper').waitFor({state:'hidden'});
|
||||
await page.setViewportSize({width:1165,height:747}); assert.equal(await toggle.isVisible(),false);
|
||||
assert.equal(await page.locator('.ch-sidebar').isVisible(),true);
|
||||
assert.equal((await metrics()).horizontalOverflow,false);
|
||||
checks.push('390px phone and desktop navigation retain layout without horizontal overflow');
|
||||
}
|
||||
assert.deepEqual(errors,[]); assert.deepEqual(blocked,[]);
|
||||
}
|
||||
} finally {
|
||||
await writeFile(resolve(output,'page.html'),await page.content());
|
||||
await page.screenshot({path:resolve(output,'final-page.png')});
|
||||
await writeFile(resolve(output,'result.json'),JSON.stringify({checks,errors,blocked},null,2));
|
||||
await context.close(); await browser?.close(); server.httpServer.closeAllConnections(); await server.close();
|
||||
}
|
||||
console.log(JSON.stringify({checks,errors,blocked}));
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
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 elderA = '10000000-0000-4000-8000-000000000411';
|
||||
const elderB = '10000000-0000-4000-8000-000000000412';
|
||||
const cacheDir = await mkdtemp(join(tmpdir(), 'home-overview-vite-'));
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('web'), cacheDir, oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error',
|
||||
// The test's virtual entry is absent from index.html. Prebundle its dependencies
|
||||
// without sharing the production checkout's optimizer cache or discovering new
|
||||
// bundles during interactions, which can otherwise reload the test document.
|
||||
optimizeDeps: { noDiscovery: true, include: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom', 'react-dom/client', 'antd', '@ant-design/icons', '@phosphor-icons/react', 'oidc-client-ts', 'go-captcha-react'] },
|
||||
server: { host: '127.0.0.1', port: 5329, strictPort: true },
|
||||
plugins: [{
|
||||
name: 'home-overview-test',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/__home-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/__home-module.tsx"></script>');
|
||||
});
|
||||
},
|
||||
resolveId(id) { if (id === '__home-module.tsx') return '\0__home-module.tsx'; },
|
||||
load(id) {
|
||||
if (id !== '\0__home-module.tsx') return;
|
||||
return `import React from 'react';import {createRoot} from 'react-dom/client';
|
||||
import {AuthProvider} from '/src/auth/useAuth.ts';import {CustomerPortal} from '/src/pages/CustomerPortal.tsx';
|
||||
import {installPortalFixtures} from '/src/dev/portal-fixtures.ts';
|
||||
import '/src/styles.css';import '/src/cal-theme.css';import '/src/customer-responsive.css';import '/src/components/home/customer-portal.css';
|
||||
const nativeFetch=window.fetch.bind(window);installPortalFixtures();const fixtureFetch=window.fetch.bind(window);
|
||||
window.__homeRequests=[];
|
||||
window.fetch=async(input,init)=>{const path=new URL(typeof input==='string'?input:input instanceof URL?input.href:input.url,location.origin).pathname;window.__homeRequests.push(path);
|
||||
if(path.endsWith('/home-overview'))return nativeFetch(input,init);
|
||||
const response=await fixtureFetch(input,init);
|
||||
if(path.endsWith('/subjects')){const body=await response.json();body.data.items.push({...body.data.items[0],id:'${elderB}',preferred_name:'爷爷'});return new Response(JSON.stringify(body),{headers:{'Content-Type':'application/json'}})}
|
||||
return response;};
|
||||
createRoot(document.getElementById('root')).render(React.createElement(AuthProvider,null,React.createElement(CustomerPortal)));`;
|
||||
},
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
|
||||
const checks = [], problems = [];
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
await page.clock.install();
|
||||
let documentLoads = 0;
|
||||
page.on('domcontentloaded', () => { documentLoads += 1; });
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', error => problems.push(error.message));
|
||||
const overview = (subjectID, summary, duration = 98000, count = 35) => ({
|
||||
subject_id: subjectID, timezone: 'Asia/Shanghai', week_start: '2026-09-28', week_end: '2026-10-05',
|
||||
conversation_count: count, total_duration_ms: count ? duration : 0,
|
||||
daily: Array.from({ length: 7 }, (_, index) => ({ date: new Date(Date.UTC(2026, 8, 28 + index)).toISOString().slice(0, 10), conversation_count: index === 3 ? count : 0, duration_ms: index === 3 && count ? duration : 0 })),
|
||||
latest_conversation: { id: `latest-${subjectID}`, source: 'call_memory', status: 'FAILED', started_at: '2026-10-01T14:00:11Z', ended_at: '2026-10-01T14:01:49Z', duration_ms: duration, summary, summary_state: 'READY', summary_kind: 'memory' },
|
||||
});
|
||||
let responseA = overview(elderA, '奶奶本次聊到阳台新开的花。');
|
||||
let responseB = overview(elderB, '爷爷本次聊到下棋。', 12000, 1);
|
||||
let failure = false, holdNext = false, heldResolve, releaseHeld;
|
||||
let held = new Promise(resolve => { heldResolve = resolve; });
|
||||
const requests = [];
|
||||
await page.route('**/api/v1/subjects/*/home-overview', async route => {
|
||||
const subjectID = route.request().url().split('/subjects/')[1].split('/')[0];
|
||||
requests.push(subjectID);
|
||||
const data = structuredClone(subjectID === elderA ? responseA : responseB);
|
||||
if (holdNext && subjectID === elderA) {
|
||||
holdNext = false; heldResolve();
|
||||
await new Promise(resolve => { releaseHeld = resolve; });
|
||||
}
|
||||
await route.fulfill(failure ? { status: 500, json: { ok: false, code: 'TEST_FAILURE', message: '近况暂时读取失败' } } : { json: { ok: true, data } }).catch(() => {});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5329/__home-test', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
const latest = page.locator('.ch-latest');
|
||||
const metrics = page.locator('.ch-metrics strong');
|
||||
const refresh = () => page.getByRole('button', { name: '刷新陪伴近况', exact: true }).click();
|
||||
await latest.getByText('奶奶本次聊到阳台新开的花。', { exact: true }).waitFor();
|
||||
await server.waitForRequestsIdle();
|
||||
assert.deepEqual(await metrics.allTextContents(), ['35', '1.6']);
|
||||
assert.match(await latest.innerText(), /聊天记忆摘要/);
|
||||
assert.match(await latest.innerText(), /10月1日 22:00 · 1分38秒/);
|
||||
assert.match(await latest.innerText(), /聊天中断/);
|
||||
assert.equal(requests.length, 1);
|
||||
const oldRequests = await page.evaluate(() => window.__homeRequests.filter(path => /\/subjects\/[^/]+\/conversations$|\/conversations\/[^/]+\/summaries$/.test(path)));
|
||||
assert.deepEqual(oldRequests, []);
|
||||
checks.push('actual CustomerPortal uses one overview request, no legacy list/summary requests, full aggregate and real FAILED latest chat');
|
||||
|
||||
await page.locator('.ch-sidebar').getByRole('link', { name: '聊天记录', exact: true }).click();
|
||||
await page.clock.runFor(30050);
|
||||
assert.equal(requests.length, 1);
|
||||
responseA = overview(elderA, '返回首页后取到的新聊天。');
|
||||
await page.locator('.ch-sidebar').getByRole('link', { name: '首页', exact: true }).click();
|
||||
await latest.getByText('返回首页后取到的新聊天。', { exact: true }).waitFor();
|
||||
assert.equal(requests.length, 2);
|
||||
checks.push('leaving home stops polling; returning refreshes the overview independently of legacy history');
|
||||
|
||||
holdNext = true;
|
||||
await refresh(); await held;
|
||||
await page.evaluate(() => { window.dispatchEvent(new Event('focus')); document.dispatchEvent(new Event('visibilitychange')); });
|
||||
assert.equal(requests.length, 3);
|
||||
await page.getByRole('button', { name: '查看爷爷的陪伴近况', exact: true }).click();
|
||||
assert.equal(await page.locator('.ch-sidebar').getByRole('link', { name: '首页', exact: true }).getAttribute('aria-current'), 'page');
|
||||
await page.getByRole('heading', { name: '爷爷的陪伴近况', exact: true }).waitFor();
|
||||
await latest.getByText('爷爷本次聊到下棋。', { exact: true }).waitFor();
|
||||
assert.deepEqual(await metrics.allTextContents(), ['1', '<1']);
|
||||
assert.equal(await page.getByText('返回首页后取到的新聊天。', { exact: true }).count(), 0);
|
||||
releaseHeld();
|
||||
await page.waitForTimeout(100);
|
||||
assert.match(await latest.innerText(), /爷爷本次聊到下棋/);
|
||||
assert.match(await latest.innerText(), /12秒/);
|
||||
checks.push('overlapping refreshes are deduplicated; switching elder clears prior data and ignores late response; short duration remains visible');
|
||||
|
||||
failure = true;
|
||||
await refresh();
|
||||
await page.getByRole('alert').filter({ hasText: '暂时无法载入陪伴近况' }).waitFor();
|
||||
assert.equal(await page.getByText('爷爷本次聊到下棋。', { exact: true }).count(), 0);
|
||||
assert.equal(await metrics.count(), 0);
|
||||
failure = false;
|
||||
responseB.latest_conversation.summary = '恢复后同步的最新聊天。';
|
||||
const beforeRetry = requests.length;
|
||||
await page.getByRole('button', { name: '重新加载', exact: true }).click();
|
||||
await latest.getByText('恢复后同步的最新聊天。', { exact: true }).waitFor();
|
||||
assert.equal(requests.length, beforeRetry + 1);
|
||||
checks.push('failed refresh removes stale metrics and summary; retry makes one request and restores latest content');
|
||||
|
||||
responseB = overview(elderB, '上周聊天仍是最近一通。', 12000, 0);
|
||||
responseB.latest_conversation.started_at = '2026-09-27T08:00:00Z';
|
||||
await refresh();
|
||||
await latest.getByText('上周聊天仍是最近一通。', { exact: true }).waitFor();
|
||||
assert.deepEqual(await metrics.allTextContents(), ['0', '0']);
|
||||
checks.push('zero chats this week does not hide the latest chat from the previous week');
|
||||
|
||||
responseB.latest_conversation.summary_state = 'INSUFFICIENT_TRANSCRIPT';
|
||||
await refresh();
|
||||
await latest.getByText('本次聊天文字不足,未生成总结。', { exact: true }).waitFor();
|
||||
responseB.latest_conversation.summary_state = 'PROCESSING';
|
||||
await refresh();
|
||||
await latest.getByText('聊天已结束,摘要正在整理中。', { exact: true }).waitFor();
|
||||
checks.push('insufficient text and processing have distinct accurate status text');
|
||||
|
||||
const longSummary = '定时刷新后的新聊天。爷爷聊起年轻时住过的院子、家门口的老树,以及和邻居一起下棋的往事。他说这些温暖的日常一直记在心里,最近也想和家人去公园走走,看看秋天的桂花,再聊一聊过去做过的拿手菜。';
|
||||
responseB = overview(elderB, longSummary, 98000, 35);
|
||||
const beforePoll = requests.length;
|
||||
await page.clock.runFor(30050);
|
||||
await latest.getByText(longSummary, { exact: true }).waitFor();
|
||||
assert.equal(requests.length, beforePoll + 1);
|
||||
checks.push('visible homepage refreshes every 30 seconds');
|
||||
|
||||
const evidence = process.env.HOME_OVERVIEW_OUTPUT_DIR;
|
||||
if (evidence) { await mkdir(evidence, { recursive: true }); await page.screenshot({ path: resolve(evidence, 'home-desktop.png'), fullPage: true }); }
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: width === 390 ? 844 : 1000 });
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) < 3);
|
||||
assert(await page.locator('.ch-latest-summary').evaluate(element => element.clientHeight >= parseFloat(getComputedStyle(element).lineHeight) * 2 - 1), 'long summary must exercise two visible lines');
|
||||
const boxes = await page.locator('.ch-weekly, .ch-latest').evaluateAll(panels => panels.map(panel => ({ panel: panel.getBoundingClientRect().bottom, content: Math.max(...Array.from(panel.querySelectorAll('*')).map(child => child.getBoundingClientRect().bottom)) })));
|
||||
assert(boxes.every(box => box.content <= box.panel + 1), `home card content exceeds panel at ${width}px: ${JSON.stringify(boxes)}`);
|
||||
}
|
||||
if (evidence) await page.screenshot({ path: resolve(evidence, 'home-mobile.png'), fullPage: true });
|
||||
checks.push('desktop and mobile show two-line memory summary plus interruption label without horizontal or card content overflow');
|
||||
assert.equal(documentLoads, 1, 'the test document must not reload during interactions');
|
||||
assert.deepEqual(problems, []);
|
||||
console.log(JSON.stringify({ checks, problems }, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ checks, problems }, null, 2));
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await server.close();
|
||||
if (dirname(cacheDir) !== resolve(tmpdir()) || !basename(cacheDir).startsWith('home-overview-vite-')) throw new Error('unexpected test cache path');
|
||||
await rm(cacheDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Real app and router, synthetic account/provider responses. No SMS or external login.
|
||||
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('.design-evidence/login-routing'); 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: 5323, strictPort: true } });
|
||||
await server.listen();
|
||||
let browser;
|
||||
const checks = [], problems = [];
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.on('pageerror', e => problems.push(e.message));
|
||||
let signedIn = false, setupLinked = false, exchanges = 0, links = 0, privateRequests = 0, revoked = false;
|
||||
const setup = () => ({ authenticated: setupLinked, closed: false, setup_id: 'synthetic-setup', sms_enabled: true, expires_at_ms: Date.now() + 600000, state: { device_id: 'test-device', binding_state: 'UNBOUND', activation_state: 'NOT_STARTED', binding_generation: 0, device_revision: 1 } });
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const r = route.request(), path = new URL(r.url()).pathname;
|
||||
if (path === '/api/v1/events') { assert(signedIn); return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': connected\n\n' }); }
|
||||
let data = {}, status = 200, code = 'OK';
|
||||
if (path === '/api/v1/web/operator/account/session') { status = 401; code = 'LOGIN_REQUIRED'; }
|
||||
else if (path === '/api/v1/web/operator/login/session') data = { sms_enabled: true, password_enabled: true, signup_enabled: false, csrf_token: 'operator-login-csrf' };
|
||||
else if (path === '/api/v1/web/operator/login/captcha') { assert.equal(r.headers()['x-csrf-token'], 'operator-login-csrf'); data = { captcha_id:'operator-captcha', image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', expires_at_ms:Date.now()+120000 }; }
|
||||
else if (path === '/api/v1/web/account/session') { if (signedIn) data = { authenticated: true, csrf_token: 'account-csrf' }; else { status = 401; code = 'LOGIN_REQUIRED'; } }
|
||||
else if (path === '/api/v1/web/login/session') data = { sms_enabled: true, signup_enabled: true, csrf_token: 'login-csrf' };
|
||||
else if (['/api/v1/web/login/captcha','/api/v1/web/auth/captcha'].includes(path)) { assert.equal(r.headers()['x-csrf-token'],path.includes('/auth/')?'setup-csrf':'login-csrf'); data={captcha_id:'captcha',image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=',expires_at_ms:Date.now()+120000}; }
|
||||
else if (path === '/api/v1/web/login/sms/send') data = { sms_challenge_id: 'challenge', resend_after_ms: 60000, expires_at_ms: Date.now() + 300000 };
|
||||
else if (path === '/api/v1/web/login/sms/verify') { signedIn = true; data = { csrf_token: 'account-csrf' }; }
|
||||
else if (path === '/api/v1/web/auth/sms/send') { assert.equal(r.headers()['x-csrf-token'], 'setup-csrf'); data = { sms_challenge_id: 'setup-challenge', resend_after_ms: 60000, expires_at_ms: Date.now() + 300000 }; }
|
||||
else if (path === '/api/v1/web/auth/sms/verify') { assert.equal(r.headers()['x-csrf-token'], 'setup-csrf'); signedIn = true; setupLinked = true; data = { csrf_token: 'linked-csrf' }; }
|
||||
else if (path === '/api/v1/me') {
|
||||
if (!signedIn || revoked) { status = 401; code = 'LOGIN_REQUIRED'; signedIn = false; }
|
||||
else data = { identity: { display_name: '路由测试用户', preferred_username: '路由测试用户' }, accounts: [] };
|
||||
} else if (['/api/v1/households', '/api/v1/devices'].includes(path)) { assert(signedIn); privateRequests++; data = { items: [] }; }
|
||||
else if (path === '/api/v1/web/auth/logout') { assert.equal(r.headers()['x-csrf-token'], 'account-csrf'); signedIn = false; }
|
||||
else if (path === '/api/v1/web/handoffs/exchange') { exchanges++; assert.deepEqual(r.postDataJSON(), { token: 'one-time-token', verifier: 'one-time-verifier' }); data = { csrf_token: 'setup-csrf' }; }
|
||||
else if (path === '/api/v1/web/setup/session') data = { csrf_token: setupLinked ? 'linked-csrf' : 'setup-csrf', data: setup() };
|
||||
else if (path === '/api/v1/web/setup/auth-link') { assert(signedIn); assert.equal(r.headers()['x-csrf-token'], 'setup-csrf'); assert.equal(r.headers().authorization, undefined); links++; setupLinked = true; data = { csrf_token: 'linked-csrf' }; }
|
||||
else if (path === '/api/v1/web/setup/status') data = setup();
|
||||
else if (path === '/api/v1/web/profiles') data = { items: [{ household_id: 'family', household_name: '测试家庭', elder_id: 'elder', preferred_name: '奶奶' }] };
|
||||
else if (path === '/api/v1/web/onboarding/devices') data = { items: [] };
|
||||
else if (path === '/api/v1/web/account/password') data = { available: false };
|
||||
else throw new Error(`Unexpected API: ${path}`);
|
||||
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ ok: status === 200, code, message: code, data }) });
|
||||
});
|
||||
const origin = 'http://127.0.0.1:5323';
|
||||
const localURL = () => { const u = new URL(page.url()); return u.pathname + u.search + u.hash; };
|
||||
async function login() {
|
||||
await page.getByLabel('手机号', { exact: true }).fill('13800000000');
|
||||
await page.getByLabel('图片验证码', { exact: true }).fill('654321');
|
||||
await page.getByRole('button', { name: '获取验证码' }).click();
|
||||
await page.getByLabel('短信验证码', { exact: true }).fill('654321');
|
||||
await page.getByRole('button', { name: /^登\s*录$/ }).click();
|
||||
}
|
||||
for (const section of ['home', 'members', 'subjects', 'devices', 'conversations', 'stories', 'sharing', 'privacy', 'billing', 'messages', 'profile']) {
|
||||
const target = `/?section=${section}&record=test%2F123#details`;
|
||||
await page.goto(origin + target);
|
||||
await page.getByRole('heading', { name: '手机号登录' }).waitFor();
|
||||
assert.equal(new URL(page.url()).searchParams.get('returnTo'), target);
|
||||
assert.equal(await page.locator('.customer-layout').count(), 0);
|
||||
}
|
||||
assert.equal(privateRequests, 0);
|
||||
checks.push('all 11 customer sections redirect before mounting business content or requesting business data');
|
||||
await login(); await page.getByRole('button', { name: '打开账户菜单' }).waitFor();
|
||||
assert.equal(localURL(), '/?section=profile&record=test%2F123#details');
|
||||
await page.reload(); await page.getByRole('button', { name: '打开账户菜单' }).waitFor();
|
||||
assert.equal(localURL(), '/?section=profile&record=test%2F123#details');
|
||||
checks.push('successful login and reload preserve path, query parameters and fragment');
|
||||
await page.goto(origin + '/login'); await page.getByRole('button', { name: '打开账户菜单' }).waitFor();
|
||||
assert.equal(localURL(), '/?section=profile&record=test%2F123#details');
|
||||
checks.push('an authenticated visit to /login returns to the last business page');
|
||||
await page.getByRole('button', { name: '打开账户菜单' }).click();
|
||||
await page.getByRole('menuitem', { name: '退出登录' }).click();
|
||||
await page.getByRole('heading', { name: '手机号登录' }).waitFor();
|
||||
assert.equal(new URL(page.url()).searchParams.get('returnTo'), '/?section=profile&record=test%2F123#details');
|
||||
assert.equal(await page.getByText('路由测试用户', { exact: true }).count(), 0);
|
||||
await page.goBack(); await page.getByRole('heading', { name: '手机号登录' }).waitFor();
|
||||
assert.equal(await page.locator('.customer-layout').count(), 0);
|
||||
checks.push('logout retains the intended destination and browser Back cannot reveal private content');
|
||||
await login(); await page.getByRole('button', { name: '打开账户菜单' }).waitFor();
|
||||
await page.getByRole('button', { name: '打开提醒与待办' }).click();
|
||||
await page.getByRole('heading', { name: '提醒与待办', exact: true }).waitFor();
|
||||
const beforeExpiry = localURL();
|
||||
revoked = true;
|
||||
await page.evaluate(async () => { const { platformApi } = await import('/src/api/client.ts'); await platformApi.currentUser('').catch(() => {}); });
|
||||
await page.getByRole('heading', { name: '手机号登录' }).waitFor();
|
||||
assert.equal(new URL(page.url()).searchParams.get('returnTo'), beforeExpiry);
|
||||
checks.push('session expiry returns to login using the latest in-app navigation target');
|
||||
revoked = false;
|
||||
for (const path of ['/maintenance?area=config', '/onboarding/devices', '/onboarding/setup', '/?designPreview=1']) {
|
||||
await page.goto(origin + path);
|
||||
await page.getByRole('heading', { name: path.startsWith('/maintenance') ? '维护中心登录' : '手机号登录' }).waitFor();
|
||||
assert.equal(new URL(page.url()).searchParams.get('returnTo'), path);
|
||||
assert.equal(await page.locator('.customer-layout,.maintenance-layout,.onboarding-shell').count(), 0);
|
||||
}
|
||||
checks.push('maintenance, onboarding and preview entry points also require login');
|
||||
const results = await page.evaluate(async () => {
|
||||
const { safeReturnURL } = await import('/src/auth/navigation.ts');
|
||||
return ['https://evil.example/', '//evil.example/', '/\\evil.example/', '/login?returnTo=/login', '/auth/callback?code=x', '/%2f%2fevil.example', '/api/v1/me', '/onboarding/entry#token=secret'].map(safeReturnURL);
|
||||
});
|
||||
assert.deepEqual(results, ['/', '/', '/', '/', '/', '/', '/', '/onboarding/setup']);
|
||||
await page.goto(origin + '/login?returnTo=' + encodeURIComponent('//evil.example/'));
|
||||
await login(); await page.getByRole('button', { name: '打开账户菜单' }).waitFor(); assert.equal(localURL(), '/');
|
||||
checks.push('unsafe return URLs and login/callback loops fall back to the homepage');
|
||||
// Exercise the real OIDC startSignin without contacting a provider.
|
||||
const oidcDestination = '/?section=profile&record=oidc#detail';
|
||||
const state = await page.evaluate(async target => {
|
||||
const { userManager, startSignin } = await import('/src/auth/oidc.ts');
|
||||
let captured;
|
||||
userManager.signinRedirect = async args => { captured = args.state; };
|
||||
await startSignin(target); return captured;
|
||||
}, oidcDestination);
|
||||
assert.deepEqual(state, { returnTo: oidcDestination });
|
||||
checks.push('account login carries the same validated destination in OIDC state');
|
||||
const callbacks = await page.evaluate(async target => {
|
||||
const { userManager, completeSigninIfNeeded } = await import('/src/auth/oidc.ts');
|
||||
let count = 0;
|
||||
userManager.signinRedirectCallback = async () => { count++; return { state: { returnTo: target } }; };
|
||||
history.replaceState({}, '', '/auth/callback?code=synthetic');
|
||||
await Promise.all([completeSigninIfNeeded(), completeSigninIfNeeded()]); return count;
|
||||
}, oidcDestination);
|
||||
assert.equal(callbacks, 1); assert.equal(localURL(), oidcDestination);
|
||||
checks.push('OIDC callback restores the intended destination and exchanges a code only once');
|
||||
signedIn = false;
|
||||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
|
||||
await page.getByRole('heading', { name: '手机号登录' }).waitFor();
|
||||
assert.equal(await page.locator('.customer-layout').count(), 0);
|
||||
checks.push('restoring a browser-cached document revalidates authentication before showing business content');
|
||||
signedIn = true;
|
||||
await page.goto(origin + '/onboarding/setup');
|
||||
await page.getByLabel('选择使用者', { exact: true }).waitFor();
|
||||
assert.equal(links, 1);
|
||||
checks.push('an existing SMS account links to the pending device setup after login');
|
||||
signedIn = false;
|
||||
setupLinked = false;
|
||||
await page.goto(origin + '/onboarding/entry#token=one-time-token&verifier=one-time-verifier');
|
||||
await page.getByRole('heading', { name: '手机号登录' }).waitFor();
|
||||
assert.equal(exchanges, 1); assert.equal(new URL(page.url()).searchParams.get('returnTo'), '/onboarding/setup');
|
||||
assert(!page.url().includes('one-time'));
|
||||
assert(!await page.evaluate(() => JSON.stringify(sessionStorage).includes('one-time')));
|
||||
await login(); await page.getByRole('heading', { name: '连接家人的陪伴设备' }).waitFor();
|
||||
await page.getByLabel('选择使用者', { exact: true }).waitFor();
|
||||
assert.equal(localURL(), '/onboarding/setup'); assert.equal(links, 1); assert.equal(exchanges, 1);
|
||||
checks.push('device handoff is exchanged once, secrets are scrubbed, SMS login resumes the same setup');
|
||||
await page.screenshot({ path: resolve(output, 'onboarding-resumed.png'), fullPage: true });
|
||||
assert.deepEqual(problems, []);
|
||||
await writeFile(resolve(output, 'result.json'), JSON.stringify({ checks, problems, boundary: 'Synthetic account and provider responses; no real SMS or OIDC login.' }, null, 2));
|
||||
console.log(JSON.stringify({ checks, problems }));
|
||||
} finally { await browser?.close(); await server.close(); }
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
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 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: 'memory-visibility-test',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/__memory-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/__memory-module.tsx"></script>');
|
||||
});
|
||||
},
|
||||
resolveId(id) { if (id === '__memory-module.tsx') return '\0__memory-module.tsx'; },
|
||||
load(id) {
|
||||
if (id !== '\0__memory-module.tsx') return;
|
||||
return `import React from 'react';import {createRoot} from 'react-dom/client';
|
||||
import {MemoryWorkspace} from '/src/pages/MemoryWorkspace.tsx';
|
||||
import '/src/styles.css';import '/src/cal-theme.css';import '/src/customer-responsive.css';import '/src/components/home/customer-portal.css';
|
||||
function App(){const [elder,setElder]=React.useState('elder-a');const [section,setSection]=React.useState('daily');
|
||||
return React.createElement('div',{className:'companion-portal'},React.createElement('button',{onClick:()=>setElder('elder-b')},'切换长者'),React.createElement('button',{onClick:()=>setSection('memory')},'查看故事'),React.createElement('button',{onClick:()=>setSection('daily')},'查看日常'),
|
||||
React.createElement(MemoryWorkspace,{key:section,elderID:elder,accessToken:'synthetic',section}))};
|
||||
createRoot(document.getElementById('root')).render(React.createElement(App));`;
|
||||
},
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
|
||||
const checks = [], problems = [];
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.setDefaultTimeout(12000);
|
||||
page.on('pageerror', e => problems.push(e.message));
|
||||
const record = (number, type = 'EVENT') => ({
|
||||
memory_id: `memory-${number}`, record_version: 1, memory_type: type, content: `聊天总结${number}`,
|
||||
status: 'ACTIVE', proactive_policy: 'ALLOWED', sensitivity: 'normal', source_refs: [],
|
||||
created_at: new Date(Date.UTC(2026, 9, 1, 22, number)).toISOString(), updated_at: '2026-10-02T01:00:00Z',
|
||||
});
|
||||
const baseStatus = { memory_revision: 27, viking_visible_revision: 27, privacy_epoch: 0, sync_state: 'IN_SYNC', critical_backlog: false, pending_count: 0, blocked_count: 0, upload_pending_count: 0, extracting_count: 0, synced_call_count: 27, insufficient_transcript_count: 0 };
|
||||
let records = Array.from({ length: 27 }, (_, index) => record(27 - index));
|
||||
let status = { ...baseStatus, sync_state: 'LAGGING_NORMAL', extracting_count: 1, insufficient_transcript_count: 2 };
|
||||
let statusFails = false, queryFails = false, paginate = false, holdNextQuery = false, releaseQuery;
|
||||
let heldRequest;
|
||||
const held = new Promise(resolve => { heldRequest = resolve; });
|
||||
await page.route('**/api/v1/memories/**', async route => {
|
||||
const body = route.request().postDataJSON();
|
||||
const op = new URL(route.request().url()).pathname.split('/memories/')[1];
|
||||
if (op === 'sync/status') {
|
||||
if (statusFails) return route.fulfill({ status: 500, json: { error: { code: 'SYNC_UNAVAILABLE', message: 'synthetic failure' } } });
|
||||
return route.fulfill({ json: { data: status } });
|
||||
}
|
||||
if (op === 'query') {
|
||||
if (queryFails) return route.fulfill({ status: 500, json: { error: { code: 'QUERY_UNAVAILABLE', message: 'synthetic failure' } } });
|
||||
const items = body.elder_id === 'elder-b' ? [record(99)] : paginate ? (body.cursor ? [record(1)] : [record(3), record(2)]) : records;
|
||||
const data = { items, mutations: [], can_write: true, can_manage_access: false, next_cursor: paginate && !body.cursor ? 'page-2' : '' };
|
||||
if (holdNextQuery && body.elder_id === 'elder-a') {
|
||||
holdNextQuery = false;
|
||||
heldRequest();
|
||||
await new Promise(resolve => { releaseQuery = resolve; });
|
||||
}
|
||||
await route.fulfill({ json: { data } }).catch(() => {});
|
||||
}
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5328/__memory-test');
|
||||
const rows = page.locator('.cp-memory-row');
|
||||
await rows.nth(26).waitFor();
|
||||
assert.equal(await rows.count(), 27);
|
||||
assert.deepEqual(await rows.locator('strong').allTextContents(), records.map(item => item.content));
|
||||
assert.equal(await rows.first().locator('time').getAttribute('datetime'), records[0].created_at);
|
||||
await page.getByRole('status').filter({ hasText: '等待提取 1 通聊天' }).waitFor();
|
||||
await page.getByText('2通聊天文字不足,未生成总结', { exact: true }).waitFor();
|
||||
checks.push('27 records retain server order and creation timestamps; extraction and insufficient text coexist');
|
||||
|
||||
statusFails = true; records = [record(40)];
|
||||
await page.getByRole('button', { name: /刷\s*新/ }).click();
|
||||
await rows.first().filter({ hasText: '聊天总结40' }).waitFor();
|
||||
await page.getByRole('status').filter({ hasText: '同步状态读取失败' }).waitFor();
|
||||
assert.equal(await page.getByText('✓ 记忆已同步', { exact: true }).count(), 0);
|
||||
statusFails = false; status = baseStatus;
|
||||
await page.getByRole('button', { name: /刷\s*新/ }).click();
|
||||
await page.getByRole('status').filter({ hasText: '✓ 记忆已同步' }).waitFor();
|
||||
checks.push('successful query survives failed status and status recovers on refresh');
|
||||
|
||||
queryFails = true;
|
||||
await page.getByRole('button', { name: /刷\s*新/ }).click();
|
||||
await page.getByRole('status').filter({ hasText: '记忆读取失败' }).waitFor();
|
||||
queryFails = false; paginate = true;
|
||||
await page.getByRole('button', { name: /刷\s*新/ }).click();
|
||||
await rows.first().filter({ hasText: '聊天总结3' }).waitFor();
|
||||
assert.equal(await page.getByRole('alert').count(), 0);
|
||||
await page.getByRole('button', { name: '加载更多', exact: true }).click();
|
||||
await rows.nth(2).waitFor();
|
||||
assert.deepEqual(await rows.locator('strong').allTextContents(), ['聊天总结3', '聊天总结2', '聊天总结1']);
|
||||
await page.getByRole('button', { name: /刷\s*新/ }).click();
|
||||
await rows.nth(2).waitFor();
|
||||
assert.deepEqual(await rows.locator('strong').allTextContents(), ['聊天总结3', '聊天总结2', '聊天总结1']);
|
||||
checks.push('query recovery clears the error and pagination preserves server order across refresh');
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) < 3);
|
||||
assert.match(await rows.first().innerText(), /整理时间/);
|
||||
checks.push('mobile layout has no horizontal overflow and shows creation time');
|
||||
|
||||
paginate = false; holdNextQuery = true;
|
||||
await page.getByRole('button', { name: /刷\s*新/ }).click();
|
||||
await held;
|
||||
await page.getByRole('button', { name: '切换长者', exact: true }).click();
|
||||
await rows.first().filter({ hasText: '聊天总结99' }).waitFor();
|
||||
releaseQuery();
|
||||
await page.waitForTimeout(150);
|
||||
assert.deepEqual(await rows.locator('strong').allTextContents(), ['聊天总结99']);
|
||||
checks.push('late response from previous elder cannot replace the new elder records');
|
||||
|
||||
await page.getByRole('button', { name: '查看故事', exact: true }).click();
|
||||
await page.getByText('自动整理的聊天总结请查看“日常”;这里保留人物关系、个人信息、偏好与人生故事。', { exact: true }).waitFor();
|
||||
assert.equal(await rows.count(), 0);
|
||||
await page.getByRole('button', { name: '查看日常', exact: true }).click();
|
||||
await rows.first().filter({ hasText: '聊天总结99' }).waitFor();
|
||||
checks.push('daily/story guidance retains the existing record categories');
|
||||
assert.deepEqual(problems, []);
|
||||
console.log(JSON.stringify({ checks, problems }, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Real React component; synthetic API responses. No customer data or provider calls.
|
||||
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 root=resolve('web'),output=resolve('.design-evidence/call-memory');await mkdir(output,{recursive:true});
|
||||
const server=await createServer({configFile:false,root,oxc:{jsx:{runtime:'automatic'}},logLevel:'error',server:{host:'127.0.0.1',port:5321,strictPort:true},plugins:[{
|
||||
name:'memory-test-harness',
|
||||
configureServer(server){server.middlewares.use('/__memory-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="/@vite/client"></script><script type="module" src="/@id/__memory-module"></script>');});},
|
||||
resolveId(id){if(id==='__memory-module')return '\0__memory-module';},
|
||||
load(id){if(id==='\0__memory-module')return `import React from 'react';import {createRoot} from 'react-dom/client';import {MemoryWorkspace} from '/src/pages/MemoryWorkspace.tsx';import 'antd/dist/reset.css';createRoot(document.getElementById('root')).render(React.createElement(MemoryWorkspace,{elderID:'test-elder',accessToken:'synthetic-test-token'}));`;}
|
||||
}]});
|
||||
await server.listen();
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH??'C:/Program Files/Google/Chrome/Application/chrome.exe'});
|
||||
const problems=[],checks=[];
|
||||
try{
|
||||
const page=await browser.newPage({viewport:{width:1280,height:960}});page.on('pageerror',e=>{problems.push(e.message);console.error(e.message);});
|
||||
|
||||
|
||||
let recordVersion=1,denied=false;const posted=[];
|
||||
const record={memory_id:'11111111-1111-4111-8111-111111111111',record_version:1,content:'喜欢喝淡茶',memory_type:'PREFERENCE',status:'ACTIVE',proactive_policy:'ALLOWED',sensitivity:'normal',entity_key:'tea',source_refs:[],updated_at:new Date().toISOString()};
|
||||
const task={mutation_id:'22222222-2222-4222-8222-222222222222',mutation_revision:1,mutation_state:'NEEDS_CONFIRMATION',action:'DELETE',targets:[record.memory_id],patch:{},expected_record_versions:{[record.memory_id]:1},stage_states:{main_store:'PENDING',serving_projection:'PENDING',derived_context:'PENDING',active_context:'PENDING',source_cleanup:'PENDING',backup_cleanup:'PENDING'},scope_summary:'SOURCE_AND_DERIVED',feedback_state:'PAGE_ONLY'};
|
||||
await page.route('**/api/v1/memories/**',async route=>{
|
||||
const request=route.request(),op=new URL(request.url()).pathname.split('/memories/')[1],body=request.postDataJSON();assert.equal(request.headers().authorization,'Bearer synthetic-test-token');assert(request.headers()['x-request-id']);
|
||||
let data={};let status=200,code='OK';
|
||||
if(denied){status=403;code='MEMORY_ACCESS_DENIED';}
|
||||
else if(op==='query')data={items:[{...record,record_version:recordVersion}],mutations:[task],next_cursor:'',can_write:true,can_manage_access:true};
|
||||
else if(op==='sync/status')data={memory_revision:2,viking_visible_revision:1,critical_backlog:true,pending_count:1,blocked_count:1,error_code:'PROJECTION_SCHEMA_UNVERIFIED'};
|
||||
else if(op==='mutations'){posted.push(body);if(body.action==='CORRECT'){status=409;code='RECORD_VERSION_CONFLICT';}else data={...task,mutation_state:'PROPAGATING'};}
|
||||
else if(op==='mutations/resolve')data={mutation_id:task.mutation_id,mutation_revision:2,proposal_digest:'test-digest',confirmation_token:'single-use-test-token',expires_at_ms:Date.now()+600000,targets:body.targets,patch:body.patch,action:'DELETE',scope:'SOURCE_AND_DERIVED',expected_record_versions:body.expected_record_versions};
|
||||
else if(op==='mutations/confirm'){posted.push(body);data={...task,mutation_state:'PROPAGATING'};}
|
||||
else if(op==='access/query')data={items:[{user_id:'owner',display_name:'家庭所有者',owner:true,can_read:true,can_write:true,sensitive_read:true,version:1},{user_id:'member',display_name:'家庭成员',owner:false,can_read:false,can_write:false,sensitive_read:false,version:0}]};
|
||||
await route.fulfill({status,contentType:'application/json',body:JSON.stringify({ok:status===200,code,message:code,data,request_id:'test',server_time_ms:Date.now()})});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5321/__memory-test');await page.getByRole('button',{name:/喜欢喝淡茶/}).click();await page.screenshot({path:resolve(output,'initial.png'),fullPage:true});await page.getByRole('button',{name:'编辑记忆',exact:true}).waitFor();
|
||||
await page.getByRole('button',{name:'编辑记忆',exact:true}).click();recordVersion=2;
|
||||
// Let the actual poll run while the editor is open; submission must retain v1.
|
||||
await page.waitForTimeout(5500);await page.getByLabel('事实正文').fill('只喝温的淡茶');
|
||||
await page.getByRole('dialog').getByRole('button',{name:/OK|确 定|确定/}).click();
|
||||
await page.getByRole('dialog').getByText('这条记忆已被其他人修改,请刷新后核对新版本。').waitFor();
|
||||
assert.equal(posted[0].expected_record_versions[record.memory_id],1);checks.push('poll does not silently replace reviewed record version');
|
||||
await page.getByRole('dialog').getByRole('button',{name:/Cancel|取 消|取消/}).click();
|
||||
await page.locator('summary').filter({hasText:'更多操作'}).click();await page.getByRole('button',{name:'删除记忆',exact:true}).click();
|
||||
const dialog=page.getByRole('dialog');assert(await dialog.getByRole('button',{name:/OK|确 定|确定/}).isDisabled());
|
||||
await dialog.getByLabel('已核对目标和删除范围').check();await dialog.getByRole('button',{name:/OK|确 定|确定/}).click();
|
||||
await page.getByText('变更已保存').waitFor();assert.equal(posted[1].scope,'MEMORY_AND_DERIVED');checks.push('delete requires explicit scope review');
|
||||
await page.locator('summary').filter({hasText:'变更记录'}).click();await page.getByRole('button',{name:'核对并确认'}).click();await page.getByRole('dialog').getByLabel('已核对目标和删除范围').check();await page.getByRole('dialog').getByRole('button',{name:/OK|确 定|确定/}).click();
|
||||
await page.getByText('操作:删除 · 范围:记忆、衍生内容及对应原始文字').waitFor();
|
||||
await page.waitForTimeout(400);await page.screenshot({path:resolve(output,'confirm-source-delete.png'),fullPage:true});
|
||||
await page.getByRole('button',{name:'确认执行'}).click();await page.getByRole('button',{name:'记忆授权'}).click();
|
||||
await page.getByRole('dialog').getByText('家庭成员',{exact:true}).waitFor();assert.equal(await page.getByRole('dialog').getByRole('checkbox',{disabled:true}).count(),3);checks.push('owner controls remain immutable');
|
||||
await page.getByRole('dialog').getByRole('button',{name:/^关\s*闭$/}).click();
|
||||
await page.getByRole('dialog').waitFor({state:'hidden'});await page.screenshot({path:resolve(output,'desktop.png'),fullPage:true});
|
||||
await page.setViewportSize({width:375,height:900});await page.waitForTimeout(400);await page.screenshot({path:resolve(output,'mobile.png'),fullPage:true});
|
||||
const overflow=await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth);assert(overflow<3,`mobile document overflow ${overflow}`);checks.push('mobile viewport has no document overflow');
|
||||
denied=true;await page.getByRole('button',{name:/^刷\s*新$/}).click();await page.getByText('当前账号没有这位长者的记忆访问权限。').waitFor();
|
||||
assert(await page.getByRole('button',{name:/添加记忆/}).isDisabled());assert.equal(await page.getByText('喜欢喝淡茶',{exact:true}).count(),0);checks.push('revoked access clears prior plaintext');
|
||||
assert.deepEqual(problems,[]);await writeFile(resolve(output,'result.json'),JSON.stringify({checks,problems},null,2));console.log(JSON.stringify({checks,problems}));
|
||||
}finally{await browser.close();await server.close();}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Real login/router/API clients; synthetic delivery and business responses.
|
||||
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 server=await createServer({configFile:false,root:resolve('web'),oxc:{jsx:{runtime:'automatic'}},logLevel:'error',server:{host:'127.0.0.1',port:5325,strictPort:true}});
|
||||
await server.listen();const output=resolve('.design-evidence/operator-phone');await mkdir(output,{recursive:true});
|
||||
let browser;const checks=[],errors=[];
|
||||
try {
|
||||
browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH??'C:/Program Files/Google/Chrome/Application/chrome.exe'});
|
||||
const page=await browser.newPage({viewport:{width:1280,height:1000}});
|
||||
page.on('pageerror',e=>errors.push(e.message));
|
||||
let customer=true,operator=false,recentSMS=false,captcha=0,opReads=0,writes=0,sends=0;
|
||||
const tenant='10000000-0000-0000-0000-000000000011';
|
||||
await page.route('**/*',async route=>{
|
||||
const r=route.request(),path=new URL(r.url()).pathname;
|
||||
if(!path.startsWith('/api/')&&!path.startsWith('/health/'))return route.continue();
|
||||
const body=r.method()==='POST'?r.postDataJSON():undefined;
|
||||
let status=200,data={},code='OK',message='';
|
||||
if(path.startsWith('/health/'))data={status:path.endsWith('/live')?'alive':'ready',service:'api',checks:{database:'ready'}};
|
||||
else if(path==='/api/v1/version')data={version:'test'};
|
||||
else if(path==='/api/v1/web/operator/account/session'){if(operator)data={authenticated:true,csrf_token:'operator-csrf'};else{status=401;code='LOGIN_REQUIRED';}}
|
||||
else if(path==='/api/v1/web/account/session'){assert(customer);data={authenticated:true,csrf_token:'customer-csrf'};}
|
||||
else if(path==='/api/v1/web/operator/login/session')data={sms_enabled:true,password_enabled:true,signup_enabled:false,csrf_token:'operator-login-csrf'};
|
||||
else if(path==='/api/v1/web/operator/login/captcha'){
|
||||
assert.equal(r.headers()['x-csrf-token'],'operator-login-csrf');captcha++;
|
||||
data={captcha_id:`image-${captcha}`,image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=',expires_at_ms:Date.now()+120000};
|
||||
} else if(path==='/api/v1/web/operator/login/password'){
|
||||
assert.equal(r.headers()['x-csrf-token'],'operator-login-csrf');assert.equal(body.phone_e164,'+8618941657635');assert.equal(body.captcha_id,`image-${captcha}`);
|
||||
if(body.captcha_code!=='654321'){status=401;code='CAPTCHA_INVALID';message='图片验证码错误或已过期,请重新输入';}
|
||||
else{assert.equal(body.password,'OperatorOnly123!');operator=true;recentSMS=false;data={csrf_token:'operator-csrf'};}
|
||||
} else if(path==='/api/v1/web/operator/login/sms/send'){
|
||||
assert.equal(r.headers()['x-csrf-token'],'operator-login-csrf');assert.equal(body.captcha_id,`image-${captcha}`);assert.equal(body.captcha_code,'654321');sends++;
|
||||
data={sms_challenge_id:'sms-op',expires_at_ms:Date.now()+300000,resend_after_ms:60000};
|
||||
} else if(path==='/api/v1/web/operator/login/sms/verify'){assert.equal(body.code,'654321');operator=true;recentSMS=true;data={csrf_token:'operator-csrf'};}
|
||||
else if(path==='/api/v1/me'){
|
||||
const op=r.headers()['x-login-portal']==='operator';
|
||||
if(op&&!operator){status=401;code='LOGIN_REQUIRED';}
|
||||
else data={identity:{subject:op?'admin':'owner',display_name:op?'测试管理员':'测试家庭用户'},accounts:op?[{tenant_id:tenant,tenant_name:'测试组织',user_id:'admin',platform_role:'ops_admin',account_kind:'operator',household_memberships:[]}]:[]};
|
||||
} else if(path==='/api/v1/web/operator/account/password'){
|
||||
assert(operator);if(r.method()==='POST'){assert.equal(r.headers()['x-csrf-token'],'operator-csrf');assert.equal(body.current_password,'OperatorOnly123!');writes++;}
|
||||
data={available:true,phone_masked:'+86189****7635',password_configured:true,sms_verified_recently:recentSMS};
|
||||
} else if(path==='/api/v1/web/account/password')data={available:false};
|
||||
else if(path==='/api/v1/web/operator/auth/logout'){assert.equal(r.headers()['x-csrf-token'],'operator-csrf');operator=false;}
|
||||
else if(path.startsWith('/api/v1/ops/')){
|
||||
assert(operator);assert.equal(r.headers()['x-login-portal'],'operator');assert.equal(r.headers().authorization,undefined);assert.equal(r.headers()['x-tenant-id'],tenant);
|
||||
if(r.method()==='POST'){assert.equal(r.headers()['x-csrf-token'],'operator-csrf');writes++;}else opReads++;
|
||||
data={items:[]};
|
||||
} else if(['/api/v1/households','/api/v1/devices'].includes(path))data={items:[]};
|
||||
else throw new Error('Unexpected API '+path);
|
||||
await route.fulfill({status,contentType:'application/json',body:JSON.stringify({ok:status===200,code,message,data})});
|
||||
});
|
||||
const origin='http://127.0.0.1:5325',target='/maintenance?area=devices&record=123#detail';
|
||||
await page.goto(origin+target);await page.getByRole('heading',{name:'维护中心登录'}).waitFor();
|
||||
assert.equal(opReads,0);assert.equal(await page.locator('.maintenance-layout').count(),0);
|
||||
const phone=page.getByLabel('手机号',{exact:true});await phone.fill('18941657635');
|
||||
assert(await page.getByRole('button',{name:'获取验证码',exact:true}).isDisabled());
|
||||
const bounds=await page.locator('.login-panel').boundingBox();
|
||||
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('img',{name:'图片验证码',exact:true}).waitFor();
|
||||
assert.deepEqual(await page.locator('.login-panel').boundingBox(),bounds);assert.equal(await phone.inputValue(),'18941657635');
|
||||
checks.push('customer session cannot enter management; both admin login modes share one card');
|
||||
await page.getByLabel('密码',{exact:true}).fill('OperatorOnly123!');await page.getByLabel('图片验证码',{exact:true}).fill('000000');await page.locator('form.sms-login-form button[type=submit]').click();
|
||||
await page.getByText('图片验证码错误或已过期,请重新输入',{exact:true}).waitFor();await page.waitForFunction(()=>!document.querySelector('#customer-image-code').disabled);
|
||||
await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.locator('form.sms-login-form button[type=submit]').click();
|
||||
await page.getByRole('button',{name:'打开管理账户菜单'}).waitFor();assert.equal(page.url(),origin+target);
|
||||
await page.reload();await page.getByRole('button',{name:'打开管理账户菜单'}).waitFor();assert(opReads>0);
|
||||
checks.push('operator password/captcha login, API reads and reload retain original destination');
|
||||
await page.evaluate(async tenant=>{const {authenticatedJSON}=await import('/src/api/client.ts');await authenticatedJSON('POST','/api/v1/ops/devices','',undefined,{}, {'X-Tenant-Id':tenant});const {onboardingRequest}=await import('/src/api/onboarding.ts');await onboardingRequest('/api/v1/ops/device-inventory',{}, {tenant,token:''});},tenant);
|
||||
assert.equal(writes,2);checks.push('both operator API clients send operator CSRF and tenant without an empty Bearer');
|
||||
await page.getByRole('button',{name:'打开管理账户菜单'}).click();await page.getByRole('menuitem',{name:'登录与安全'}).click();await page.getByText('手机号:+86189****7635').waitFor();
|
||||
await page.getByLabel('当前密码',{exact:true}).fill('OperatorOnly123!');await page.getByLabel('新密码',{exact:true}).fill('ChangedOperator123!');await page.getByLabel('确认新密码',{exact:true}).fill('ChangedOperator123!');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('密码已更新').waitFor();
|
||||
await page.locator('.ant-drawer-close').click();
|
||||
checks.push('management account menu offers isolated password settings');
|
||||
await page.getByRole('button',{name:'打开管理账户菜单'}).click();await page.getByRole('menuitem',{name:'退出登录'}).click();await page.getByRole('heading',{name:'维护中心登录'}).waitFor();assert(customer);
|
||||
await page.evaluate(async()=>{const {navigateTo}=await import('/src/auth/navigation.ts');navigateTo('/?section=profile');});await page.getByRole('button',{name:'打开账户菜单'}).waitFor();
|
||||
await page.evaluate(async target=>{const {navigateTo}=await import('/src/auth/navigation.ts');navigateTo(target);},target);await page.getByRole('heading',{name:'维护中心登录'}).waitFor();
|
||||
checks.push('operator logout preserves customer session; switching portals reloads the correct identity');
|
||||
await phone.fill('18941657635');await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();await page.getByText(/验证码已发送/).waitFor();assert.equal(sends,1);
|
||||
await page.getByLabel('短信验证码',{exact:true}).fill('654321');await page.locator('form.sms-login-form button[type=submit]').click();await page.getByRole('button',{name:'打开管理账户菜单'}).waitFor();
|
||||
checks.push('operator SMS login requires image verification and returns to management');
|
||||
operator=false;await page.evaluate(async()=>{const {platformApi}=await import('/src/api/client.ts');await platformApi.currentUser('').catch(()=>{});});await page.getByRole('heading',{name:'维护中心登录'}).waitFor();assert(customer);
|
||||
for(const width of [375,320]){await page.setViewportSize({width,height:920});await page.waitForFunction(()=>!document.querySelector('#customer-phone').disabled);assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));}
|
||||
await page.screenshot({path:resolve(output,'mobile.png'),fullPage:true});checks.push('expired management session returns to its login page; 320px and 375px layouts fit');
|
||||
assert.deepEqual(errors,[]);await writeFile(resolve(output,'result.json'),JSON.stringify({checks,errors},null,2));console.log(JSON.stringify({checks,errors}));
|
||||
}finally{await browser?.close();await server.close();}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Real UI/router with synthetic HTTP responses. Backend checks run separately against PostgreSQL/Redis.
|
||||
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 server=await createServer({configFile:false,root:resolve('web'),oxc:{jsx:{runtime:'automatic'}},logLevel:'error',server:{host:'127.0.0.1',port:5324,strictPort:true}});
|
||||
const output=resolve('.design-evidence/phone-password');await mkdir(output,{recursive:true});
|
||||
await server.listen();let browser;
|
||||
const checks=[],errors=[],sentPurposes=[];
|
||||
try {
|
||||
browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH??'C:/Program Files/Google/Chrome/Application/chrome.exe'});
|
||||
const page=await browser.newPage({viewport:{width:1280,height:900}});
|
||||
page.on('pageerror',e=>errors.push(e.message));await page.clock.install();
|
||||
let signedIn=false,smsEnabled=true,captchaCount=0,sendCount=0,passwordAttempts=0,configured=true,recentSMS=false,saveCount=0,trustedDevice=false;
|
||||
await page.route('**/api/v1/**',async route=>{
|
||||
const r=route.request(),path=new URL(r.url()).pathname,body=r.method()==='POST'?r.postDataJSON():undefined;
|
||||
let status=200,code='OK',message='',data={};
|
||||
if(path==='/api/v1/web/account/session'){if(signedIn)data={authenticated:true,csrf_token:'account-csrf'};else{status=401;code='LOGIN_REQUIRED';}}
|
||||
else if(path==='/api/v1/web/login/session')data={sms_enabled:smsEnabled,password_enabled:true,csrf_token:'login-csrf'};
|
||||
else if(path==='/api/v1/web/login/captcha'){
|
||||
assert.equal(r.headers()['x-csrf-token'],'login-csrf');captchaCount++;
|
||||
data={captcha_id:`captcha-${captchaCount}`,image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=',expires_at_ms:await page.evaluate(()=>Date.now()+120000)};
|
||||
} else if(path==='/api/v1/web/login/password'){
|
||||
passwordAttempts++;assert.equal(r.headers()['x-csrf-token'],'login-csrf');assert.equal(body.phone_e164,'+8613800000000');assert.equal(body.captcha_id,`captcha-${captchaCount}`);
|
||||
if(body.captcha_code!=='654321'){status=401;code='CAPTCHA_INVALID';message='图片验证码错误或已过期,请重新输入';}
|
||||
else if(body.password!=='LocalOnly123!'){status=401;code='PASSWORD_INVALID';message='手机号或密码不正确';}
|
||||
else if(!trustedDevice){status=403;code='NEW_DEVICE_SMS_REQUIRED';message='首次在此设备登录,请使用手机验证码完成验证';}
|
||||
else{signedIn=true;recentSMS=false;data={csrf_token:'account-csrf'};}
|
||||
} else if(path==='/api/v1/web/login/sms/send'){assert.equal(body.captcha_code,'654321');assert.equal(body.captcha_id,`captcha-${captchaCount}`);sentPurposes.push(body.purpose);sendCount++;data={sms_challenge_id:'sms-challenge',expires_at_ms:await page.evaluate(()=>Date.now()+300000),resend_after_ms:60000};}
|
||||
else if(path==='/api/v1/web/login/sms/verify'){signedIn=true;trustedDevice=true;recentSMS=true;data={csrf_token:'account-csrf'};}
|
||||
else if(path==='/api/v1/web/account/password'){
|
||||
if(r.method()==='GET')data={available:true,phone_masked:'+86138****0000',password_configured:configured,sms_verified_recently:recentSMS};
|
||||
else {assert(signedIn);assert.equal(r.headers()['x-csrf-token'],'account-csrf');if(!recentSMS)assert.equal(body.current_password,'LocalOnly123!');assert.equal(body.password,'abc123');saveCount++;configured=true;recentSMS=false;}
|
||||
} else if(path==='/api/v1/me'){assert(signedIn);data={identity:{display_name:'手机号测试用户'},accounts:[]};}
|
||||
else if(path==='/api/v1/web/auth/logout'){signedIn=false;}
|
||||
else throw new Error(`Unexpected API ${path}`);
|
||||
await route.fulfill({status,contentType:'application/json',body:JSON.stringify({ok:status===200,code,message,data})});
|
||||
});
|
||||
const origin='http://127.0.0.1:5324',target='/?section=profile&record=123#details';
|
||||
await page.goto(origin+target);await page.getByRole('heading',{name:'手机号登录'}).waitFor();
|
||||
const phone=page.getByLabel('手机号',{exact:true}),submit=page.locator('form.sms-login-form').getByRole('button',{name:/^登\s*录$/});
|
||||
await phone.fill('13800000000');assert(await page.getByRole('button',{name:'获取验证码',exact:true}).isDisabled());await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();
|
||||
await page.getByText(/验证码已发送/).waitFor();const smsBounds=await page.locator('.login-panel').boundingBox();const loginURL=page.url();
|
||||
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('img',{name:'图片验证码',exact:true}).waitFor();
|
||||
assert.equal(page.url(),loginURL);assert.equal(await phone.inputValue(),'13800000000');
|
||||
const passwordBounds=await page.locator('.login-panel').boundingBox();assert.deepEqual(passwordBounds,smsBounds);
|
||||
assert.equal(await page.getByRole('button',{name:'账号登录',exact:true}).count(),0);
|
||||
checks.push('same card position and size, phone preserved, no external login or username field');
|
||||
await page.getByRole('button',{name:'验证码登录',exact:true}).click();assert.equal(await phone.inputValue(),'13800000000');assert.equal(sendCount,1);assert(await page.getByRole('button',{name:/秒后重发/}).isDisabled());
|
||||
checks.push('switching back preserves SMS challenge and resend countdown');
|
||||
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByLabel('密码',{exact:true}).fill('LocalOnly123!');
|
||||
const beforeRejected=captchaCount;assert(await submit.isDisabled());await page.getByLabel('图片验证码',{exact:true}).fill('000000');await submit.click();
|
||||
await page.getByText('图片验证码错误或已过期,请重新输入',{exact:true}).waitFor();await page.waitForFunction(()=>document.querySelector('#customer-image-code').value==='');
|
||||
assert.equal(passwordAttempts,1);assert.equal(captchaCount,beforeRejected+1);
|
||||
checks.push('captcha is required and refreshed after a rejected login');
|
||||
await page.getByRole('button',{name:'换一张',exact:true}).click();await page.waitForFunction(()=>!document.querySelector('#customer-image-code').disabled);assert.equal(captchaCount,beforeRejected+2);
|
||||
await page.clock.fastForward(121000);await page.getByText('图片验证码已过期,请换一张',{exact:true}).waitFor();
|
||||
assert(await submit.isDisabled());await page.getByRole('button',{name:'换一张',exact:true}).click();
|
||||
checks.push('manual refresh and captcha expiry prevent stale submissions');
|
||||
for(const width of [375,320]){await page.setViewportSize({width,height:850});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));}
|
||||
checks.push('password and captcha fit 320px and 375px screens');
|
||||
await page.getByLabel('图片验证码',{exact:true}).fill('654321');await submit.click();
|
||||
await page.getByText('首次在此浏览器登录,请完成手机验证码验证。验证后记住此浏览器 30 天。',{exact:true}).waitFor();
|
||||
assert.equal(signedIn,false);assert.equal(await phone.inputValue(),'13800000000');assert.equal(await page.getByLabel('密码',{exact:true}).count(),0);
|
||||
await page.screenshot({path:resolve(output,'new-device-sms-mobile.png')});
|
||||
await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();
|
||||
await page.getByLabel('短信验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'验证并登录'}).click();await page.getByRole('button',{name:'打开账户菜单'}).waitFor();
|
||||
checks.push('new device redirects to existing LOGIN SMS verification without granting a password-only session');
|
||||
assert.equal(page.url(),origin+target);await page.reload();await page.getByText('手机号:+86138****0000').waitFor();
|
||||
checks.push('password login and reload restore the original path, query and fragment');
|
||||
await page.getByLabel('新密码',{exact:true}).fill('abc12');await page.getByLabel('确认新密码',{exact:true}).fill('abc12');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('密码须为 6–64 个字符').waitFor();assert.equal(saveCount,0);
|
||||
await page.getByLabel('新密码',{exact:true}).fill('abc123');await page.getByLabel('确认新密码',{exact:true}).fill('abc123');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('密码已更新',{exact:true}).waitFor();assert.equal(saveCount,1);
|
||||
checks.push('profile allows password changes with current password and account CSRF');
|
||||
await page.getByRole('button',{name:'打开账户菜单'}).click();await page.getByRole('menuitem',{name:'退出登录'}).click();await page.getByRole('heading',{name:'手机号登录'}).waitFor();
|
||||
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('button',{name:'忘记密码'}).click();
|
||||
await phone.fill('13800000000');assert(await page.getByRole('button',{name:'获取验证码',exact:true}).isDisabled());await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();await page.getByLabel('短信验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'验证并重置密码'}).click();
|
||||
await page.getByRole('dialog',{name:'重置密码'}).waitFor();assert.equal(await page.getByLabel('当前密码',{exact:true}).count(),0);
|
||||
await page.getByLabel('新密码',{exact:true}).fill('abc123');await page.getByLabel('确认新密码',{exact:true}).fill('mismatch');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('两次输入的密码不一致').waitFor();assert.equal(saveCount,1);
|
||||
await page.getByLabel('确认新密码',{exact:true}).fill('abc123');await page.getByRole('button',{name:'保存密码'}).click();await page.getByRole('button',{name:'打开账户菜单'}).waitFor();assert.equal(saveCount,2);assert.equal(page.url(),origin+target);
|
||||
assert.deepEqual(sentPurposes,['LOGIN','LOGIN','CHANGE_PASSWORD']);
|
||||
checks.push('forgot password uses the password-change template purpose, verifies SMS, confirms the new password, and preserves destination');
|
||||
signedIn=false;smsEnabled=false;await page.goto(origin+'/login?returnTo='+encodeURIComponent(target));await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('img',{name:'图片验证码',exact:true}).waitFor();
|
||||
checks.push('password login remains available when SMS delivery is not configured');
|
||||
assert.deepEqual(errors,[]);await writeFile(resolve(output,'result.json'),JSON.stringify({checks,errors},null,2));console.log(JSON.stringify({checks,errors}));
|
||||
}finally{await browser?.close();await server.close();}
|
||||
@@ -0,0 +1,63 @@
|
||||
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('.tmp-portal-records-fix/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:5322,strictPort:true},plugins:[{
|
||||
name:'portal-records-test',configureServer(server){server.middlewares.use('/__records-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/__records-module.tsx"></script>');});},resolveId(id){if(id==='__records-module.tsx')return '\0__records-module.tsx';},load(id){if(id==='\0__records-module.tsx')return `
|
||||
import React from 'react';import {createRoot} from 'react-dom/client';
|
||||
import {CallHistory} from '/src/pages/CallHistory.tsx';import {MemoryWorkspace} from '/src/pages/MemoryWorkspace.tsx';
|
||||
import {installPortalFixtures} from '/src/dev/portal-fixtures.ts';import {AuthProvider} from '/src/auth/useAuth.ts';import {CustomerPortal} from '/src/pages/CustomerPortal.tsx';
|
||||
import '/src/styles.css';import '/src/cal-theme.css';import '/src/customer-responsive.css';import '/src/components/home/customer-portal.css';
|
||||
const params=new URL(location.href).searchParams,mode=params.get('mode');
|
||||
if(mode==='portal') {
|
||||
const nativeFetch=window.fetch.bind(window);installPortalFixtures();const previous=window.fetch.bind(window);let desired=60,actual=60,revision=1,reported=1;
|
||||
window.applyReported=()=>{actual=desired;reported=revision;};
|
||||
window.fetch=async(input,init)=>{const path=new URL(typeof input==='string'?input:input.url,location.origin).pathname;
|
||||
if(path.includes('/memories/calls/'))return nativeFetch(input,init);
|
||||
if(path.endsWith('/twin')) { if(init?.method==='PATCH'){desired=JSON.parse(init.body).desired_config.volume.speaker;revision++;}
|
||||
return new Response(JSON.stringify({data:{desired_config:{volume:{speaker:desired},power:{mode:'active'}},reported_config:{volume:{speaker:actual},power:{mode:'active'}},desired_revision:revision,reported_revision:reported,sync_state:revision===reported?'applied':'pending'}}),{headers:{'Content-Type':'application/json'}}); }
|
||||
return previous(input,init);
|
||||
};
|
||||
createRoot(document.getElementById('root')).render(React.createElement(AuthProvider,null,React.createElement(CustomerPortal)));
|
||||
} else {function App(){const [elder,setElder]=React.useState('elder-a');return React.createElement('div',{className:'companion-portal'},React.createElement('button',{onClick:()=>setElder('elder-b')},'切换长者'),mode==='memory'?React.createElement(MemoryWorkspace,{elderID:elder,accessToken:'synthetic',section:params.get('section')??undefined}):React.createElement(CallHistory,{key:elder,elderID:elder,accessToken:'synthetic'}))};createRoot(document.getElementById('root')).render(React.createElement(App));}`;}
|
||||
}]});
|
||||
await server.listen();const browser=await chromium.launch({headless:true,executablePath:'C:/Program Files/Google/Chrome/Application/chrome.exe'});
|
||||
const checks=[],problems=[];
|
||||
try {
|
||||
const page=await browser.newPage({viewport:{width:1440,height:1000}});page.setDefaultTimeout(12000);page.on('pageerror',e=>problems.push(e.message));
|
||||
let denied=false,slow=false;const requests=[];
|
||||
const record=(id)=>({id,created_at:'2026-09-26T01:00:00Z',ended_at:'2026-09-26T01:02:00Z',status:'ENDED',transcript_state:'COMPLETE',duration_ms:120000});
|
||||
await page.route('**/api/v1/memories/**',async route=>{const body=route.request().postDataJSON(),op=new URL(route.request().url()).pathname.split('/memories/')[1];requests.push({op,body});let data={};
|
||||
if(denied) return route.fulfill({status:403,json:{error:{code:'TRANSCRIPT_ACCESS_DENIED',message:'denied'}}});
|
||||
if(op==='calls/query') {if(body.elder_id==='elder-b')data={items:[],next_cursor:''};else data={items:body.cursor?[record('second')]:[record('first')],next_cursor:body.cursor?'':'page2'};}
|
||||
if(op==='calls/turns'){if(slow)await new Promise(r=>setTimeout(r,500));data={items:[{id:body.cursor?'u2':'u1',speaker:body.cursor?'ASSISTANT':'USER',final_text:body.cursor?'今天阳光很好,我们一起聊聊天。':'今天我去公园散步了。',created_at:'2026-09-26T01:00:00Z',interrupted:false}],next_cursor:body.cursor?'':'turn2'};}
|
||||
if(op==='query')data={items:[{memory_id:'event',memory_type:'EVENT',content:'今天去公园散步',proactive_policy:'ALLOWED',source_refs:[],updated_at:new Date().toISOString()},{memory_id:'preference',memory_type:'PREFERENCE',content:'喜欢喝淡茶',proactive_policy:'RESPOND_ONLY',source_refs:[],updated_at:new Date().toISOString()}],mutations:[],next_cursor:'',can_write:true};
|
||||
if(op==='sync/status')data={pending_count:0};
|
||||
await route.fulfill({json:{data}});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5322/__records-test');
|
||||
await page.locator('.cp-memory-row').first().click();await page.getByText('今天我去公园散步了。',{exact:true}).waitFor();
|
||||
await page.getByRole('button',{name:'加载更多对话'}).click();await page.getByText('今天阳光很好,我们一起聊聊天。',{exact:true}).waitFor();
|
||||
await page.getByRole('button',{name:'加载更多聊天'}).click();await page.locator('.cp-memory-row').nth(1).waitFor();assert.equal(await page.locator('.cp-memory-row').count(),2);
|
||||
assert.equal(requests.find(r=>r.op==='calls/turns').body.business_session_id,'first');checks.push('new call list and transcript pagination');
|
||||
await page.screenshot({path:resolve(output,'calls-desktop.png'),fullPage:true});
|
||||
await page.setViewportSize({width:390,height:844});assert(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)<3);await page.screenshot({path:resolve(output,'calls-mobile.png'),fullPage:true});checks.push('mobile call layout');
|
||||
slow=true;await page.locator('.cp-memory-row').last().click();await page.getByRole('button',{name:'切换长者'}).click();await page.getByRole('heading',{name:'还没有聊天记录'}).waitFor();await page.waitForTimeout(650);assert.equal(await page.getByText('今天我去公园散步了。',{exact:true}).count(),0);checks.push('late responses do not cross elders');
|
||||
denied=true;await page.getByRole('button',{name:'刷新记录'}).click();await page.getByRole('alert').filter({hasText:'没有查看聊天文字的权限'}).waitFor();checks.push('permission denial is not shown as an empty history');denied=false;
|
||||
for(const section of ['daily','memory']) {await page.goto('http://127.0.0.1:5322/__records-test?mode=memory§ion='+section);await page.locator('.cp-memory-row').waitFor();assert.equal(await page.locator('.cp-memory-row').count(),1);assert.match(await page.locator('.cp-memory-row').innerText(),section==='daily'?/公园/:/淡茶/);}
|
||||
checks.push('daily and memory separate actual record types');
|
||||
await page.setViewportSize({width:1440,height:1000});await page.goto('http://127.0.0.1:5322/__records-test?mode=portal§ion=devices');await page.getByRole('heading',{name:'设备已连接'}).waitFor();
|
||||
assert.equal(await page.locator('.cp-connected').evaluate(e=>getComputedStyle(e).color),'rgb(33, 131, 75)');
|
||||
await page.getByRole('button',{name:'查看设备详情',exact:true}).click();await page.getByRole('slider').fill('56');await page.getByRole('button',{name:'保存音量配置',exact:true}).click();
|
||||
const form=page.getByRole('form',{name:'家庭业务受控操作'});await form.getByRole('checkbox').check();await form.locator('button[type=submit]').click();
|
||||
await page.getByText('音量设置已保存,正在等待设备确认',{exact:true}).first().waitFor();await page.evaluate(()=>window.applyReported());await page.getByText('音量已生效,设备实际音量 56%',{exact:true}).first().waitFor();
|
||||
assert.equal(await page.getByText('音量设置已保存,正在等待设备确认',{exact:true}).count(),0);checks.push('saved volume becomes confirmed after reported revision; connected check is green');await page.screenshot({path:resolve(output,'volume-confirmed.png'),fullPage:true});
|
||||
await page.goto('http://127.0.0.1:5322/__records-test?mode=portal§ion=conversations');await page.locator('[aria-label="设备聊天记录"] .cp-memory-row').first().click();await page.getByText('今天我去公园散步了。',{exact:true}).waitFor();await page.screenshot({path:resolve(output,'portal-calls.png'),fullPage:true});
|
||||
await page.goto('http://127.0.0.1:5322/__records-test?mode=portal§ion=stories');await page.locator('.cp-tabs').getByRole('button',{name:'日常',exact:true}).waitFor();await page.locator('.cp-tabs').getByRole('button',{name:'故事',exact:true}).click();await page.getByText('喜欢在阳台照顾花草。看到花开会觉得心情很好。',{exact:true}).first().waitFor();await page.screenshot({path:resolve(output,'portal-stories.png'),fullPage:true});checks.push('full portal shows current records and daily/story tabs');
|
||||
assert.deepEqual(problems,[]);await writeFile(resolve(output,'result.json'),JSON.stringify({checks,problems},null,2));console.log(JSON.stringify({checks,problems}));
|
||||
}finally{await browser.close();await server.close();}
|
||||
@@ -0,0 +1,321 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
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-saas-repair/evidence');
|
||||
await mkdir(output, { recursive: true });
|
||||
await mkdir(resolve(output, 'empty-env'), { recursive: true });
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('web'), envDir: resolve(output, 'empty-env'), cacheDir: resolve(output, 'vite-cache'),
|
||||
oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error',
|
||||
server: { host: '127.0.0.1', port: 0 }, plugins: [{
|
||||
name: 'saas-repair-regression',
|
||||
configureServer(server) { server.middlewares.use('/__repair-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/__repair-module.tsx"></script>');
|
||||
}); },
|
||||
resolveId(id) { if (id === '__repair-module.tsx') return '\0__repair-module.tsx'; },
|
||||
load(id) { if (id === '\0__repair-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')||'subjects');
|
||||
return React.createElement(SaaSWorkspace,{accounts,accessToken:'synthetic-only',section,onSectionChange:setSection,redesignedHome:true,showNavigation:false});
|
||||
}createRoot(document.getElementById('root')).render(React.createElement(App));`; },
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
const browser = await chromium.launch({ headless: true, ...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}) });
|
||||
const checks = [], problems = [], allRequests = [], blockedExternal = [], browserDiagnostics = [];
|
||||
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 });
|
||||
const originalSubject = { 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();
|
||||
const privacySummary = { id:'qa-request', subject_id:'qa-elder', household_id:'A', request_type:'DELETE', status:'EXECUTING', version:1, created_at:now, updated_at:now, result_summary:{}, subtasks:[] };
|
||||
const privacyDetail = { ...privacySummary, version:2, updated_at:new Date(Date.now()+1000).toISOString(), subtasks:[{id:'qa-subtask',subsystem:'postgresql',status:'FAILED',attempts:2,last_error:'合成回归:详情应在列表刷新后继续可见'}] };
|
||||
const sourceFiles = ['web/src/pages/SaaSWorkspace.tsx','web/src/pages/subject-save.ts','web/src/components/SubjectConsentDialog.tsx','web/src/components/AccessibleDialog.tsx','web/src/components/CreationConsentDialog.tsx','web/src/components/HouseholdNameDialog.tsx','web/src/components/DeviceControls.tsx','web/src/components/DeviceDailyControls.tsx','web/src/components/DeviceSettingsSheet.tsx','web/src/components/home/CustomerHomePage.tsx','web/src/api/client.ts'];
|
||||
const fingerprint = async () => Object.fromEntries(await Promise.all(sourceFiles.map(async file => [file, createHash('sha256').update(await readFile(file)).digest('hex')])));
|
||||
const before = await fingerprint();
|
||||
let page, currentScenario = '';
|
||||
|
||||
async function makePage(options = {}) {
|
||||
const context = await browser.newContext({ viewport: { width:1165, height:747 }, timezoneId:'America/Los_Angeles', serviceWorkers:'block' });
|
||||
if (options.selectedA !== false) await context.addInitScript(() => localStorage.setItem('companion:household:qa-tenant/qa-user','A'));
|
||||
page = await context.newPage(); page.setDefaultTimeout(20000);
|
||||
const fixture = { subject: structuredClone(originalSubject), families:[household('B','QA家庭B'),household('A','QA家庭A')], patchMode:'success', patches:0, reads:0, privacyReads:0, verifyGETFailure:false, householdBFailure:false, grantFailure:false, homeFailure:false, readinessFailure:false, privacy:false, requests:[], ...options };
|
||||
page.on('pageerror', error => problems.push({ scenario:currentScenario, error:error.message }));
|
||||
page.on('console', message => { if(message.type()==='error') browserDiagnostics.push({scenario:currentScenario,error:message.text()}); });
|
||||
page.on('response', response => { if(response.status()>=400&&!new URL(response.url()).pathname.startsWith('/api/')) browserDiagnostics.push({scenario:currentScenario,status:response.status(),path:new URL(response.url()).pathname}); });
|
||||
await page.route('**/*', async route => {
|
||||
const request = route.request(), url = new URL(request.url());
|
||||
if (url.origin !== origin) { blockedExternal.push({scenario:currentScenario,url:url.origin+url.pathname}); return route.abort('blockedbyclient'); }
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
const path=url.pathname, method=request.method(), body=request.postData()?request.postDataJSON():undefined;
|
||||
const record={scenario:currentScenario,path,method,body,query:Object.fromEntries(url.searchParams),ifMatch:request.headers()['if-match']}; fixture.requests.push(record); allRequests.push(record);
|
||||
const answer = data => route.fulfill({json:{ok:true,data,request_id:'synthetic-qa'},headers:{ETag:'"v1"'}});
|
||||
const fail = () => route.fulfill({status:502,contentType:'text/html',body:'<html><body>Synthetic gateway response lost</body></html>'});
|
||||
if (path==='/api/v1/events') return route.fulfill({contentType:'text/event-stream',body:fixture.eventBody ?? ': synthetic offline test\n\n'});
|
||||
if (path==='/api/v1/households') return answer({items:fixture.families});
|
||||
if (/\/households\/[^/]+\/subjects$/.test(path) && method==='GET') {
|
||||
fixture.reads++; if (fixture.verifyGETFailure && fixture.patches>0) return fail();
|
||||
if (fixture.householdBFailure && path.includes('/B/')) return fail();
|
||||
return answer({items:path.includes('/A/')?[fixture.subject]:[]});
|
||||
}
|
||||
if (/^\/api\/v1\/households\/[^/]+$/.test(path) && method==='PATCH') {
|
||||
const id=path.split('/').at(-1), previous=fixture.families.find(item=>item.id===id);
|
||||
const changed={...previous,name:body.name,version:previous.version+1};
|
||||
fixture.families=fixture.families.map(item=>item.id===id?changed:item);return answer(changed);
|
||||
}
|
||||
if (path==='/api/v1/subjects/qa-elder' && method==='PATCH') {
|
||||
assert.equal(request.headers()['if-match'],`"v${fixture.subject.version}"`,'Profile PATCH must retain the server concurrency version');
|
||||
fixture.patches++;
|
||||
if (fixture.patchMode==='rejected-400') return route.fulfill({status:400,json:{ok:false,error:{code:'SYNTHETIC_REJECTED',message:'合成校验拒绝:请检查资料'}}});
|
||||
if (fixture.patchMode==='conflict-502') fixture.subject={...fixture.subject,preferred_name:'QA服务器另一版本',version:fixture.subject.version+1};
|
||||
if (fixture.patchMode==='success'||fixture.patchMode==='committed-502') fixture.subject={...fixture.subject,...body,version:fixture.subject.version+1};
|
||||
return fixture.patchMode==='success'?answer(fixture.subject):fail();
|
||||
}
|
||||
if (path.endsWith('/members')) return answer({items:[{id:'qa-user',user_id:'qa-user',display_name:'合成测试成员',role:'owner',status:'active',is_owner:true,version:1,joined_at:now}]});
|
||||
if (/\/households\/[^/]+\/privacy-requests$/.test(path)) { fixture.privacyReads++; return answer({items:fixture.privacy?[privacySummary]:[]}); }
|
||||
if (path==='/api/v1/privacy-requests/qa-request') return answer(privacyDetail);
|
||||
if (fixture.grantFailure && (path.endsWith('/recording-access-grants')||path.endsWith('/sharing-grants'))) return fail();
|
||||
if (path.endsWith('/conversation-readiness')||path.endsWith('/readiness')) return fixture.readinessFailure?fail():answer({subject_id:originalSubject.id,ready:false,checks:[],devices:[],start_time_checks:[]});
|
||||
if (path.endsWith('/home-overview')) return fixture.homeFailure?fail():answer({subject_id:originalSubject.id,timezone:'Asia/Shanghai',week_start:'2026-10-05',week_end:'2026-10-11',conversation_count:0,total_duration_ms:0,daily:[],latest_conversation:null});
|
||||
if (method==='GET'||(method==='POST'&&path.endsWith('/query'))) return answer({items:[],next_cursor:''});
|
||||
problems.push({scenario:currentScenario,error:`Unexpected mutation: ${method} ${path}`});
|
||||
return route.fulfill({status:400,json:{ok:false,error:{code:'UNEXPECTED_TEST_MUTATION',message:'Not supported by synthetic fixture'}}});
|
||||
});
|
||||
return {context,fixture};
|
||||
}
|
||||
const open = async section => { await page.goto(`${origin}/__repair-test?section=${section}`); await page.locator('.ch-sidebar .ch-household').waitFor({state:'attached'}); };
|
||||
const chooseA = async () => { await page.getByRole('button',{name:'切换家庭',exact:true}).click(); await page.getByRole('menuitem',{name:'QA家庭A',exact:true}).click(); await page.getByRole('button',{name:'切换家庭',exact:true}).filter({hasText:'QA家庭A'}).waitFor(); };
|
||||
async function submitSubject(name, closes=true) {
|
||||
await page.locator('.cp-identity button').click();
|
||||
await page.getByRole('button',{name:'基本资料',exact:true}).click();
|
||||
await page.locator('.cp-profile-fields label').filter({has:page.locator('strong',{hasText:'称呼'})}).locator('input').fill(name);
|
||||
await page.getByRole('button',{name:'保存资料',exact:true}).click();
|
||||
assert.equal(await page.locator('#family-controlled-operation').count(),0,'Low-risk profile save must not ask for another confirmation');
|
||||
await page.waitForFunction(() => document.querySelector('.cp-profile-actions')?.textContent?.match(/服务器已确认保存|保存结果尚未确认|本次修改未保存/));
|
||||
}
|
||||
const screenshot = name => page.screenshot({path:resolve(output,name)});
|
||||
|
||||
try {
|
||||
for (const mode of ['success','committed-502','uncommitted-502','verify-get-502']) {
|
||||
currentScenario=`save-${mode}`;
|
||||
const {context,fixture}=await makePage({patchMode:mode==='verify-get-502'?'uncommitted-502':mode,verifyGETFailure:mode==='verify-get-502'});
|
||||
await open('subjects'); await submitSubject(`QA修改-${mode}`);
|
||||
assert.equal(fixture.patches,1);
|
||||
if (mode==='success') {
|
||||
await page.locator('.cp-profile-actions').getByText('服务器已确认保存',{exact:true}).waitFor();
|
||||
assert.equal(await page.getByRole('button',{name:'保存资料',exact:true}).isDisabled(),true);
|
||||
assert.equal(fixture.subject.preferred_name,`QA修改-${mode}`);
|
||||
checks.push('SAVE01: successful PATCH is confirmed and leaves no dirty save');
|
||||
} else {
|
||||
const verify=page.getByRole('button',{name:'核验服务器资料(只读)',exact:true}); await verify.waitFor();
|
||||
assert.match(await page.locator('.cp-profile-actions').innerText(),/保存结果尚未确认/);
|
||||
assert.equal(await page.getByRole('button',{name:'保存资料',exact:true}).isDisabled(),true);
|
||||
const readsBefore=fixture.reads;
|
||||
await screenshot(`${currentScenario}-unknown-1165.png`);
|
||||
await verify.click();
|
||||
if (mode==='committed-502') {
|
||||
await page.locator('.cp-profile-actions').getByText('已核验:服务器资料与本次修改一致',{exact:true}).waitFor();
|
||||
assert.equal(await verify.count(),0);
|
||||
checks.push('SAVE02: committed PATCH with 502 stays unknown until GET verifies matching values; no duplicate PATCH');
|
||||
} else {
|
||||
await page.getByText(mode==='verify-get-502'?/核验失败,保存结果仍未确认/:/服务器当前尚未反映本次修改/).first().waitFor();
|
||||
assert.match(await page.locator('.cp-profile-actions').innerText(),/保存结果尚未确认/);
|
||||
assert.equal(await page.getByRole('button',{name:'保存资料',exact:true}).isDisabled(),true);
|
||||
checks.push(mode==='verify-get-502'?'SAVE04: verification GET failure retains unknown outcome and blocks re-PATCH':'SAVE03: uncommitted 502 remains unknown after GET and blocks re-PATCH');
|
||||
}
|
||||
assert.ok(fixture.reads>readsBefore); assert.equal(fixture.patches,1);
|
||||
await screenshot(`${currentScenario}-verified-1165.png`);
|
||||
if (mode==='uncommitted-502') { await page.setViewportSize({width:390,height:844}); await screenshot('save-unknown-390.png'); }
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
|
||||
currentScenario='save-rejected-draft';
|
||||
let session=await makePage({patchMode:'rejected-400'});
|
||||
await open('subjects'); await submitSubject('QA被拒绝仍保留的草稿',false);
|
||||
await page.getByText(/合成校验拒绝/).first().waitFor();
|
||||
assert.equal(await page.locator('.cp-profile-fields label').filter({has:page.locator('strong',{hasText:'称呼'})}).locator('input').inputValue(),'QA被拒绝仍保留的草稿');
|
||||
assert.match(await page.locator('.cp-profile-actions').innerText(),/本次修改未保存/);
|
||||
assert.equal(session.fixture.patches,1);
|
||||
checks.push('SAVE05: explicit 400 rejection preserves the edited values without another confirmation');
|
||||
await session.context.close();
|
||||
|
||||
currentScenario='save-conflict-recovery'; session=await makePage({patchMode:'conflict-502'});
|
||||
await open('subjects'); await submitSubject('QA未确认草稿');
|
||||
await page.getByRole('button',{name:'核验服务器资料(只读)',exact:true}).click();
|
||||
const adopt=page.getByRole('button',{name:'采用最新资料,放弃本次草稿',exact:true}); await adopt.waitFor();
|
||||
assert.equal(await page.getByRole('button',{name:'保存资料',exact:true}).isDisabled(),true);
|
||||
await adopt.click();
|
||||
const nameField=page.locator('.cp-profile-fields label').filter({has:page.locator('strong',{hasText:'称呼'})}).locator('input');
|
||||
assert.equal(await nameField.inputValue(),'QA服务器另一版本');
|
||||
assert.equal(await page.getByRole('button',{name:'保存资料',exact:true}).isDisabled(),true);
|
||||
assert.equal(session.fixture.patches,1);
|
||||
checks.push('SAVE06: different newer server version blocks editing until explicit adoption, without another PATCH');
|
||||
await session.context.close();
|
||||
|
||||
currentScenario='draft-survives-sync-refresh'; session=await makePage();
|
||||
session.fixture.subject.voice_agent={status:'READY',persona_sync_status:'PENDING',message:'合成同步待处理'};
|
||||
await open('subjects'); await page.locator('.cp-identity button').click(); await page.getByRole('button',{name:'基本资料',exact:true}).click();
|
||||
const draftField=page.locator('.cp-profile-fields label').filter({has:page.locator('strong',{hasText:'称呼'})}).locator('input');
|
||||
await draftField.fill('QA刷新不能覆盖的草稿');
|
||||
session.fixture.subject={...session.fixture.subject,version:2,timezone:'Asia/Tokyo',voice_agent:{status:'READY',persona_sync_status:'SYNCED',message:'合成同步已完成'}};
|
||||
await page.waitForResponse(response=>new URL(response.url()).pathname==='/api/v1/households/A/subjects',{timeout:10000});
|
||||
await page.locator('.cp-profile-form').getByText('合成同步已完成',{exact:true}).waitFor();
|
||||
assert.equal(await draftField.inputValue(),'QA刷新不能覆盖的草稿'); assert.equal(session.fixture.patches,0);
|
||||
// Persona polling only merges sync metadata. A synthetic server event exercises
|
||||
// the authoritative context refresh while retaining the real mounted editor.
|
||||
session.fixture.subject.voice_agent.message='合成资料刷新完成';
|
||||
session.fixture.eventBody='id: 1\ndata: {"id":1,"type":"subject.updated","household_id":"A","subject_id":"qa-elder"}\n\n';
|
||||
await page.waitForResponse(response=>new URL(response.url()).pathname==='/api/v1/households/A/subjects',{timeout:10000});
|
||||
await page.locator('.cp-profile-form').getByText('合成资料刷新完成',{exact:true}).waitFor();
|
||||
assert.equal(await draftField.inputValue(),'QA刷新不能覆盖的草稿');
|
||||
await page.getByRole('button',{name:'保存资料',exact:true}).click();
|
||||
await page.getByText('服务器资料在编辑期间已更新。本次草稿尚未提交,请先核对服务器新版本。',{exact:true}).waitFor();
|
||||
assert.equal(await draftField.inputValue(),'QA刷新不能覆盖的草稿');
|
||||
assert.equal(await page.locator('#family-controlled-operation').count(),0);
|
||||
assert.equal(await page.getByRole('button',{name:'保存资料',exact:true}).isDisabled(),true);
|
||||
assert.equal(session.fixture.patches,0);
|
||||
assert.equal(session.fixture.subject.timezone,'Asia/Tokyo');
|
||||
await page.getByRole('button',{name:'采用最新资料,放弃本次草稿',exact:true}).waitFor();
|
||||
await screenshot('save-concurrent-refresh-conflict-1165.png');
|
||||
checks.push('SAVE07: background v2 timezone change preserves v1 name draft; saving enters conflict with zero PATCH and requires explicit adoption');
|
||||
await session.context.close();
|
||||
|
||||
currentScenario='cross-household-list-failure'; session=await makePage({householdBFailure:true});
|
||||
await open('subjects'); await page.locator('.cp-identity').getByRole('heading',{name:'QA测试长者',exact:true}).waitFor();
|
||||
await page.getByRole('button',{name:'切换家庭',exact:true}).click();
|
||||
const failingList=page.waitForResponse(response=>new URL(response.url()).pathname==='/api/v1/households/B/subjects');
|
||||
await page.getByRole('menuitem',{name:'QA家庭B',exact:true}).click(); await failingList;
|
||||
await page.getByRole('button',{name:'切换家庭',exact:true}).filter({hasText:'QA家庭B'}).waitFor();
|
||||
assert.doesNotMatch(await page.locator('body').innerText(),/QA测试长者|仅供本地合成测试/);
|
||||
await screenshot('cross-household-failure-1165.png');
|
||||
checks.push('SCOPE01: household B list 502 cannot render household A subject data');
|
||||
await session.context.close();
|
||||
|
||||
currentScenario='family-and-tags';
|
||||
session=await makePage({selectedA:false});
|
||||
await open('members'); await chooseA(); 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();
|
||||
for (const close of ['cancel','X','backdrop','Escape','Escape']) {
|
||||
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();
|
||||
else if(close==='X') await dialog.getByRole('button',{name:'关闭编辑家庭',exact:true}).click();
|
||||
else if(close==='Escape') await page.keyboard.press('Escape');
|
||||
else await page.mouse.click(5,5);
|
||||
await dialog.waitFor({state:'detached'});
|
||||
assert.equal(await page.getByRole('button',{name:/编辑名称/}).evaluate(node=>node===document.activeElement),true,'Close returns focus to the name editor trigger');
|
||||
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();
|
||||
}
|
||||
await page.getByRole('button',{name:/编辑名称/}).click();
|
||||
const familyDialog=page.getByRole('dialog',{name:'编辑家庭'});await familyDialog.locator('input').fill('QA家庭A改名');
|
||||
await familyDialog.getByRole('button',{name:'保存',exact:true}).click();await familyDialog.waitFor({state:'detached'});
|
||||
assert.equal(session.fixture.families.find(item=>item.id==='A').name,'QA家庭A改名');
|
||||
assert.equal(session.fixture.families.find(item=>item.id==='B').name,'QA家庭B');
|
||||
checks.push('FAMILY01/02/03: reload persistence; cancel/X/backdrop/repeated Escape reset drafts and restore focus; save updates only household A');
|
||||
await open('subjects'); await page.locator('.cp-identity button').click();
|
||||
await page.getByRole('button',{name:'陪伴偏好',exact:true}).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);
|
||||
await screenshot('tags-limit-1165.png');
|
||||
checks.push('TAGS01: eighth interest disables addition and displays the explicit limit');
|
||||
await session.context.close();
|
||||
|
||||
currentScenario='memory-consent'; session=await makePage();
|
||||
await open('privacy'); await page.locator('.cp-consent-row').filter({hasText:'个性化记忆'}).getByRole('button',{name:'管理',exact:true}).click();
|
||||
const consent=page.getByRole('dialog',{name:'QA测试长者的数据授权'}); await consent.waitFor();
|
||||
assert.match(await consent.innerText(),/local-v1/); assert.match(await consent.innerText(),/尚未提供可阅读的正式政策正文/);
|
||||
assert.equal(await consent.getByRole('checkbox',{name:'个性化记忆',exact:true}).isChecked(),false);
|
||||
assert.equal(await consent.getByRole('checkbox',{name:'个性化记忆',exact:true}).isDisabled(),true);
|
||||
assert.equal(await consent.getByRole('checkbox',{checked:true}).count(),0);
|
||||
assert.equal(await consent.getByRole('button',{name:'确认保存',exact:true}).isDisabled(),true);
|
||||
await screenshot('consent-blocked-1165.png');
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
assert.ok(await consent.evaluate(node=>node.scrollWidth<=node.clientWidth+1));
|
||||
await screenshot('consent-blocked-390.png');
|
||||
assert.equal(session.fixture.requests.filter(r=>r.method==='POST'&&r.path.endsWith('/consents')).length,0);
|
||||
checks.push('CONSENT01: memory entry opens current elder data permissions, selects no unintended purpose, blocks missing policy text on desktop/mobile without writes');
|
||||
await session.context.close();
|
||||
|
||||
currentScenario='privacy-poll'; session=await makePage({privacy:true});
|
||||
await open('privacy'); const card=page.locator('#privacy-request-qa-request'); await card.waitFor();
|
||||
assert.doesNotMatch(await card.innerText(),/(?<!\d)0\/0(?!\d)/);
|
||||
await card.getByRole('button',{name:'刷新',exact:true}).click();
|
||||
await card.getByText('尚未全部清除',{exact:true}).waitFor();
|
||||
assert.equal(await card.locator(':scope > details').getAttribute('open'),null);
|
||||
assert.doesNotMatch(await card.innerText(),/合成回归:详情应在列表刷新后继续可见/);
|
||||
await card.getByText('诊断详情',{exact:true}).click();
|
||||
await card.getByText(/合成回归:详情应在列表刷新后继续可见/).first().waitFor();
|
||||
const privacyReads=session.fixture.privacyReads;
|
||||
await Promise.all([
|
||||
page.waitForResponse(response=>new URL(response.url()).pathname==='/api/v1/households/A/privacy-requests',{timeout:10000}),
|
||||
page.getByRole('heading',{name:'本家庭的数据申请',exact:true}).locator('..').getByRole('button',{name:'刷新',exact:true}).click(),
|
||||
]);
|
||||
assert.ok(session.fixture.privacyReads>privacyReads);
|
||||
assert.match(await card.innerText(),/合成回归:详情应在列表刷新后继续可见/);
|
||||
await screenshot('privacy-detail-after-poll-1165.png');
|
||||
assert.match(await card.innerText(),/最近核验:/);
|
||||
checks.push('PRIVACY01: no false 0/0; raw diagnostics stay folded; refreshing also verifies selected details and preserves evidence');
|
||||
await session.context.close();
|
||||
|
||||
currentScenario='home-partial-failure'; session=await makePage({homeFailure:true,readinessFailure:true,grantFailure:true});
|
||||
await open('home'); await page.getByRole('heading',{name:'陪伴近况暂时未能读取',exact:true}).waitFor();
|
||||
await page.getByRole('heading',{name:'陪伴准备状态待确认',exact:true}).waitFor();
|
||||
assert.equal(await page.locator('.ch-state').count(),0);
|
||||
assert.equal(await page.getByRole('button',{name:'切换家庭',exact:true}).isVisible(),true);
|
||||
await screenshot('home-partial-failure-1165.png');
|
||||
await page.setViewportSize({width:390,height:844}); await screenshot('home-partial-failure-390.png');
|
||||
session.fixture.homeFailure=false;
|
||||
await page.getByRole('button',{name:'重新加载陪伴近况',exact:true}).click();
|
||||
await page.locator('.ch-summary-grid').waitFor();
|
||||
assert.equal(await page.getByRole('heading',{name:'陪伴准备状态待确认',exact:true}).isVisible(),true);
|
||||
checks.push('HOME01: readiness and overview errors stay local; overview retry recovers independently');
|
||||
await session.context.close();
|
||||
|
||||
currentScenario='legacy-filters-beijing'; session=await makePage();
|
||||
await open('conversations');await page.getByText('查看早期聊天记录',{exact:true}).click();
|
||||
const legacy=page.locator('.conversation-layout');
|
||||
await legacy.getByRole('button',{name:'筛选日期与状态',exact:true}).click();
|
||||
const advanced=legacy.locator('.conversation-advanced-filters');
|
||||
const engineControl=advanced.locator('label').filter({hasText:'语音处理'}).locator('select');
|
||||
assert.equal(await engineControl.count(),1);
|
||||
assert.equal(await advanced.getAttribute('open'),null);assert.equal(await engineControl.isVisible(),false);
|
||||
await legacy.getByLabel('开始日期',{exact:true}).fill('2026-10-05');
|
||||
await legacy.getByLabel('结束日期',{exact:true}).fill('2026-10-05');
|
||||
await advanced.locator('summary').press('Enter');
|
||||
assert.equal(await advanced.evaluate(node=>node.open),true);
|
||||
await engineControl.selectOption('cascade');
|
||||
await page.waitForTimeout(250);
|
||||
const query=session.fixture.requests.findLast(item=>item.method==='GET'&&item.query?.engine_mode==='cascade');
|
||||
assert.ok(query,'Advanced filter preserves existing engine-mode query');
|
||||
assert.equal(query.query.from,'2026-10-04T16:00:00.000Z');assert.equal(query.query.to,'2026-10-05T16:00:00.000Z');
|
||||
assert.equal(session.fixture.requests.filter(item=>item.method!=='GET'&&!item.path.endsWith('/query')).length,0);
|
||||
checks.push('TIME01/FILTER01: LA browser uses Beijing day bounds; technical controls folded by default and remain keyboard-operable');
|
||||
await screenshot('legacy-filters-beijing-1165.png');await session.context.close();
|
||||
|
||||
assert.deepEqual(problems,[]); assert.deepEqual(blockedExternal,[]);
|
||||
const after=await fingerprint();
|
||||
assert.deepEqual(after,before,'Source changed during regression; rerun against a stable snapshot');
|
||||
await writeFile(resolve(output,'result.json'),JSON.stringify({passed:true,checks,problems,blockedExternal,browserDiagnostics,syntheticOnly:true,sourceHashes:after,requests:allRequests},null,2));
|
||||
console.log(JSON.stringify({passed:true,checks,problems,blockedExternal,output},null,2));
|
||||
} catch(error) {
|
||||
let body=''; if(page&&!page.isClosed()){await screenshot('failure.png');body=await page.locator('body').innerText();}
|
||||
await writeFile(resolve(output,'result.json'),JSON.stringify({passed:false,scenario:currentScenario,checks,problems,blockedExternal,browserDiagnostics,error:String(error),body,requests:allRequests},null,2));
|
||||
throw error;
|
||||
} finally {await browser.close();await server.close();}
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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('<!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/__subject-consent-module.tsx"></script>'); }); },
|
||||
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(); }
|
||||
@@ -0,0 +1,125 @@
|
||||
// Real form, synthetic read-only API fixtures, and no external requests.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve, join } 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 work = await mkdtemp(join(tmpdir(), 'story-subject-validation-'));
|
||||
const output = resolve(process.env.SUBJECT_VALIDATION_OUTPUT ?? '../.local-evidence/subject-create-validation-20261006');
|
||||
await mkdir(join(work, 'empty-env'));
|
||||
await mkdir(output, { recursive: true });
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('web'), envDir: join(work, 'empty-env'), cacheDir: join(work, 'vite-cache'),
|
||||
oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 },
|
||||
plugins: [{
|
||||
name: 'subject-create-validation-fixture',
|
||||
configureServer(vite) { vite.middlewares.use('/__subject-validation', (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.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/__subject-validation.tsx"></script>');
|
||||
}); },
|
||||
resolveId(id) { if (id === '__subject-validation.tsx') return '\0__subject-validation.tsx'; },
|
||||
load(id) { if (id === '\0__subject-validation.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';
|
||||
const accounts=[{tenant_id:'qa-tenant',user_id:'qa-user',account_kind:'customer',tenant_name:'合成测试',display_name:'QA',policy_version:'local-v1',household_memberships:[]}];
|
||||
createRoot(document.getElementById('root')).render(React.createElement(SaaSWorkspace,{accounts,accessToken:'synthetic-validation',section:'subjects',redesignedHome:true,showNavigation:false}));
|
||||
`; },
|
||||
}],
|
||||
});
|
||||
const errors = [], externalRequests = [], mutations = [], checks = [];
|
||||
let browser, page;
|
||||
try {
|
||||
await server.listen();
|
||||
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
browser = await chromium.launch({ headless: true, ...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}) });
|
||||
for (const viewport of [{ width: 1440, height: 1024 }, { width: 389, height: 844 }]) {
|
||||
const context = await browser.newContext({ viewport, serviceWorkers: 'block' });
|
||||
page = await context.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.route('**/*', async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname;
|
||||
if (url.origin !== origin) { externalRequests.push(url.origin + path); return route.abort('blockedbyclient'); }
|
||||
if (!path.startsWith('/api/')) return route.continue();
|
||||
if (request.method() !== 'GET') {
|
||||
mutations.push(`${request.method()} ${path}`);
|
||||
return route.fulfill({ status: 400, json: { ok: false, error: { code: 'SYNTHETIC_REJECTED', message: 'This fixture permits reads only' } } });
|
||||
}
|
||||
if (path === '/api/v1/events') return route.fulfill({ contentType: 'text/event-stream', body: ': synthetic fixture\n\n' });
|
||||
const data = path === '/api/v1/households' ? { items: [{ id: 'qa-household', tenant_id: 'qa-tenant', name: '合成测试家庭', status: 'active', role: 'owner', capabilities: ['household.read', 'subject.read', 'subject.write'], version: 1 }] } : { items: [] };
|
||||
return route.fulfill({ json: { ok: true, data, request_id: 'synthetic-validation' } });
|
||||
});
|
||||
await page.goto(`${origin}/__subject-validation`);
|
||||
await page.getByRole('button', { name: /添加长者|新增长者/ }).first().click();
|
||||
const dialog = page.locator('dialog.subject-create-dialog');
|
||||
await dialog.waitFor();
|
||||
const name = dialog.getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶');
|
||||
const background = dialog.getByPlaceholder('例如:退休前是教师,年轻时在天津生活过');
|
||||
const goal = dialog.getByPlaceholder('例如:多聊年轻时的工作经历,让长者心情放松');
|
||||
const next = dialog.getByRole('button', { name: '下一步', exact: true });
|
||||
assert(await next.isDisabled(), 'empty name remains required');
|
||||
await name.fill(' ');
|
||||
assert(await next.isDisabled(), 'whitespace name cannot proceed');
|
||||
await name.fill('合成新长者');
|
||||
assert(await next.isEnabled(), 'a name alone must enable Next when background is empty');
|
||||
assert.equal(await name.evaluate(element => element.required), true);
|
||||
assert.equal(await background.evaluate(element => element.required), false);
|
||||
assert.equal(await goal.evaluate(element => element.required), false);
|
||||
assert.match(await dialog.innerText(), /称呼(必填)/);
|
||||
assert.match(await dialog.innerText(), /背景资料(选填)/);
|
||||
assert.match(await dialog.innerText(), /可稍后补充/);
|
||||
await background.fill('仅本地合成背景');
|
||||
assert(await next.isEnabled());
|
||||
await background.fill('');
|
||||
await goal.fill('');
|
||||
assert(await next.isEnabled(), 'clearing optional fields must not block Next');
|
||||
assert(await dialog.locator('form').evaluate(element => element.checkValidity()));
|
||||
const interests = dialog.getByPlaceholder('例如:京剧、养花、做饭(用逗号分隔,最多8项)');
|
||||
await interests.fill('1,2,3,4,5,6,7,8,9');
|
||||
await dialog.getByRole('alert').getByText(/最多填写 8 项/).waitFor();
|
||||
await dialog.locator('form').evaluate(element => element.requestSubmit());
|
||||
assert(await dialog.isVisible(), 'invalid optional list is still rejected');
|
||||
assert.equal(await page.locator('dialog.creation-consent-dialog').count(), 0);
|
||||
await interests.fill('');
|
||||
const bounds = await dialog.boundingBox();
|
||||
assert(bounds.x >= 0 && bounds.x + bounds.width <= viewport.width + 1);
|
||||
assert(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1));
|
||||
await page.screenshot({ path: resolve(output, `form-${viewport.width}.png`), fullPage: true });
|
||||
await next.click();
|
||||
const consent = page.locator('dialog.creation-consent-dialog');
|
||||
await consent.waitFor();
|
||||
assert.match(await consent.innerText(), /为合成新长者开通授权/);
|
||||
assert.equal(await consent.locator('input:checked').count(), 0);
|
||||
assert.deepEqual(mutations, [], 'advancing does not create records or authorizations');
|
||||
await consent.getByRole('button', { name: '返回资料', exact: true }).click();
|
||||
await dialog.waitFor();
|
||||
assert.equal(await name.inputValue(), '合成新长者');
|
||||
assert.equal(await background.inputValue(), '');
|
||||
assert.equal(await goal.inputValue(), '');
|
||||
assert(await next.isEnabled());
|
||||
await name.fill('');
|
||||
assert(await next.isDisabled(), 'clearing the name restores the required-field block');
|
||||
await dialog.getByRole('button', { name: '取消', exact: true }).click();
|
||||
checks.push(`${viewport.width}px: required name, optional background/goal, fill/clear, list validation, authorization handoff, back/cancel, layout, zero mutations`);
|
||||
await context.close();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(externalRequests, []);
|
||||
assert.deepEqual(mutations, []);
|
||||
const result = { passed: true, checks, browserErrors: errors, externalRequests, mutations };
|
||||
await writeFile(resolve(output, 'checks.json'), JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result));
|
||||
} catch (error) {
|
||||
if (page && !page.isClosed()) await page.screenshot({ path: resolve(output, 'failure.png'), fullPage: true });
|
||||
throw error;
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Uses the full portal, read-only synthetic responses, and a disposable Chrome
|
||||
// profile. 200% is Chrome's actual page zoom, set only in this temporary profile.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve, join } 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 work = await mkdtemp(join(tmpdir(), 'story-preferences-layout-'));
|
||||
const output = resolve(process.env.SUBJECT_LAYOUT_OUTPUT ?? '../.local-evidence/subject-preferences-layout-20261006');
|
||||
await mkdir(join(work, 'empty-env'));
|
||||
await mkdir(output, { recursive: true });
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('web'), envDir: join(work, 'empty-env'), cacheDir: join(work, 'vite-cache'),
|
||||
oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 },
|
||||
plugins: [{
|
||||
name: 'subject-preferences-layout-fixture',
|
||||
configureServer(vite) { vite.middlewares.use('/__preferences-layout', (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.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/__preferences-layout.tsx"></script>');
|
||||
}); },
|
||||
resolveId(id) { if (id === '__preferences-layout.tsx') return '\0__preferences-layout.tsx'; },
|
||||
load(id) { if (id === '\0__preferences-layout.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';
|
||||
const accounts=[{tenant_id:'qa-tenant',user_id:'qa-user',account_kind:'customer',tenant_name:'合成测试',display_name:'QA',policy_version:'local-v1',household_memberships:[]}];
|
||||
createRoot(document.getElementById('root')).render(React.createElement(SaaSWorkspace,{accounts,accessToken:'synthetic-layout',section:'subjects',redesignedHome:true,showNavigation:false}));
|
||||
`; },
|
||||
}],
|
||||
});
|
||||
const errors = [], externalRequests = [], mutations = [], checks = [];
|
||||
let context, page;
|
||||
async function screenshot(path) {
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
// Capture the compositor surface without Playwright resetting viewport
|
||||
// metrics, which would conflict with Chrome's independently configured zoom.
|
||||
const session = await context.newCDPSession(page);
|
||||
try {
|
||||
const { data } = await session.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
|
||||
await writeFile(path, Buffer.from(data, 'base64'));
|
||||
} finally { await session.detach(); }
|
||||
}
|
||||
try {
|
||||
await server.listen();
|
||||
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
context = await chromium.launchPersistentContext(join(work, 'chrome-profile'), {
|
||||
headless: true, viewport: { width: 1188, height: 761 }, serviceWorkers: 'block',
|
||||
ignoreDefaultArgs: ['--hide-scrollbars'],
|
||||
...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}),
|
||||
});
|
||||
for (const scenario of [
|
||||
{ name: 'original-100', width: 1188, height: 761, zoom: 1 },
|
||||
{ name: 'narrow-100', width: 389, height: 844, zoom: 1 },
|
||||
{ name: 'original-200', width: 1188, height: 761, zoom: 2 },
|
||||
]) {
|
||||
if (scenario.zoom === 2) {
|
||||
const settings = await context.newPage();
|
||||
await settings.goto('chrome://settings/appearance');
|
||||
await settings.locator('#zoomLevel').selectOption('2');
|
||||
await settings.close();
|
||||
}
|
||||
page = await context.newPage();
|
||||
await page.setViewportSize({ width: scenario.width, height: scenario.height });
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
const subject = { id: 'qa-subject', household_id: 'qa-household', preferred_name: 'QA长者', language: 'zh-CN', timezone: 'Asia/Shanghai', status: 'active', version: 1,
|
||||
profile_data: { background: '仅本地合成背景', interests: ['养花', '听京剧', '散步', '家乡故事', '做饭', '看电影'] },
|
||||
conversation_preferences: { pace: 'slow', conversation_goal: '日常陪伴', preferred_topics: [], avoided_topics: [] },
|
||||
voice_agent: { status: 'READY', message: '专属语音助手已就绪', personalization_included: false },
|
||||
};
|
||||
await page.route('**/*', async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname;
|
||||
if (url.origin !== origin) { externalRequests.push(url.origin + path); return route.abort('blockedbyclient'); }
|
||||
if (!path.startsWith('/api/')) return route.continue();
|
||||
if (request.method() !== 'GET') {
|
||||
mutations.push(`${request.method()} ${path}`);
|
||||
return route.fulfill({ status: 400, json: { ok: false, error: { code: 'SYNTHETIC_REJECTED', message: 'This fixture permits reads only' } } });
|
||||
}
|
||||
if (path === '/api/v1/events') return route.fulfill({ contentType: 'text/event-stream', body: ': synthetic fixture\n\n' });
|
||||
const data = path === '/api/v1/households' ? { items: [{ id: 'qa-household', tenant_id: 'qa-tenant', name: '合成测试家庭', status: 'active', role: 'owner', capabilities: ['household.read', 'subject.read', 'subject.write'], version: 1 }] }
|
||||
: path === '/api/v1/households/qa-household/subjects' ? { items: [subject] }
|
||||
: path === '/api/v1/subjects/qa-subject' ? subject : { items: [] };
|
||||
return route.fulfill({ json: { ok: true, data, request_id: 'synthetic-layout' } });
|
||||
});
|
||||
await page.goto(`${origin}/__preferences-layout`);
|
||||
const metrics = await page.evaluate(() => ({ innerWidth, innerHeight, clientWidth: document.documentElement.clientWidth, dpr: devicePixelRatio }));
|
||||
assert.equal(metrics.innerWidth, scenario.width / scenario.zoom);
|
||||
assert(Math.abs(metrics.innerHeight - scenario.height / scenario.zoom) <= 1);
|
||||
assert.equal(metrics.dpr, scenario.zoom, 'verify real Chrome zoom rather than CSS scaling');
|
||||
for (let cycle = 0; cycle < 2; cycle++) {
|
||||
await page.locator('.cp-preparation').getByRole('button', { name: /交流偏好/ }).click();
|
||||
const form = page.locator('.cp-profile-form');
|
||||
const avoided = form.getByPlaceholder('填写希望避免的话题(选填),用顿号分隔');
|
||||
await avoided.waitFor();
|
||||
await avoided.evaluate(element => element.scrollIntoView({ block: 'center' }));
|
||||
const geometry = await avoided.evaluate(element => {
|
||||
const rect = el => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, bottom: r.bottom }; };
|
||||
const r = element.getBoundingClientRect(), hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
||||
const body = document.querySelector('.cp-page-detail .subject-detail-modal__body');
|
||||
const note = document.querySelector('.subject-management > .workspace__readonly-note');
|
||||
return { textarea: rect(element), centerHit: { tag: hit?.tagName, className: hit?.className },
|
||||
body: rect(body), bodyMaxHeight: getComputedStyle(body).maxHeight,
|
||||
form: rect(element.closest('form')), note: rect(note), notePosition: getComputedStyle(note).position,
|
||||
pageFooter: rect(document.querySelector('.ch-footer')),
|
||||
fields: rect(document.querySelector('.cp-profile-fields')), formActions: rect(document.querySelector('.cp-profile-actions')),
|
||||
positions: ['.cp-page-detail', '.subject-detail-modal', '.subject-detail-modal__body', '.cp-profile-form', '.cp-profile-actions'].map(selector => ({ selector, position: getComputedStyle(document.querySelector(selector)).position })),
|
||||
};
|
||||
});
|
||||
await writeFile(resolve(output, `${scenario.name}-${cycle}-geometry.json`), JSON.stringify({ metrics, ...geometry }, null, 2));
|
||||
await screenshot(resolve(output, `${scenario.name}-${cycle}.png`));
|
||||
assert.equal(geometry.centerHit.tag, 'TEXTAREA', 'avoided-topic center must hit the textarea, not the voice-agent banner');
|
||||
assert.equal(geometry.bodyMaxHeight, 'none', 'inline profile content must use its full natural height');
|
||||
assert(geometry.body.bottom >= geometry.form.bottom - 1, 'body must contain the full profile form');
|
||||
assert(geometry.note.y >= geometry.form.bottom - 1, 'voice-agent banner must follow the form');
|
||||
assert(geometry.pageFooter.y >= geometry.form.bottom - 1, 'page footer must follow the form');
|
||||
assert(geometry.formActions.y >= geometry.fields.bottom - 1, 'save actions must not overlay preference fields');
|
||||
assert.equal(geometry.notePosition, 'static');
|
||||
assert(geometry.positions.every(item => item.position === 'static'), 'inline profile containers stay in normal document flow');
|
||||
const fields = form.locator('.cp-profile-fields input, .cp-profile-fields select, .cp-profile-fields textarea');
|
||||
assert.equal(await fields.count(), 6, 'all preference controls remain present');
|
||||
for (const field of await fields.all()) {
|
||||
await field.evaluate(element => element.scrollIntoView({ block: 'center' }));
|
||||
assert(await field.evaluate(element => {
|
||||
const r = element.getBoundingClientRect();
|
||||
return [2, r.height / 2, r.height - 2].every(y => document.elementFromPoint(r.x + r.width / 2, r.y + y) === element);
|
||||
}), 'preference input top, center and bottom must remain reachable above the footer');
|
||||
}
|
||||
await avoided.evaluate(element => element.scrollIntoView({ block: 'center' }));
|
||||
const bounds = await avoided.boundingBox();
|
||||
await page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
assert(await avoided.evaluate(element => document.activeElement === element));
|
||||
await page.keyboard.insertText('仅本地测试草稿');
|
||||
assert.equal(await avoided.inputValue(), '仅本地测试草稿');
|
||||
assert.deepEqual(mutations, []);
|
||||
const save = form.getByRole('button', { name: '保存资料', exact: true });
|
||||
assert(await save.isEnabled());
|
||||
await save.evaluate(element => element.scrollIntoView({ block: 'center' }));
|
||||
assert(await save.evaluate(element => {
|
||||
const r = element.getBoundingClientRect();
|
||||
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.closest('button') === element;
|
||||
}), 'the save button stays reachable at the end of the form');
|
||||
await screenshot(resolve(output, `${scenario.name}-${cycle}-bottom.png`));
|
||||
await page.getByRole('button', { name: '关闭资料详情', exact: true }).click();
|
||||
if (cycle === 0) await page.reload();
|
||||
}
|
||||
checks.push({ scenario: scenario.name, metrics, refreshReopen: true, inputHitTests: 'all six controls, top/center/bottom', centerClickTyping: true, contentContained: true, bannerAndPageFooterAfterForm: true, saveActionsAfterFields: true, saveButtonReachable: true, allProfilePositionsStatic: true });
|
||||
await page.close();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(externalRequests, []);
|
||||
assert.deepEqual(mutations, []);
|
||||
const result = { passed: true, checks, browserErrors: errors, externalRequests, mutations };
|
||||
await writeFile(resolve(output, 'checks.json'), JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result));
|
||||
} catch (error) {
|
||||
if (page && !page.isClosed()) await screenshot(resolve(output, 'failure.png'));
|
||||
throw error;
|
||||
} finally {
|
||||
await context?.close();
|
||||
await server.close();
|
||||
// work is an absolute mkdtemp child created by this test beneath tmpdir().
|
||||
assert(resolve(work).startsWith(resolve(tmpdir()) + (process.platform === 'win32' ? '\\' : '/')));
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve, join, dirname } 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 work=await mkdtemp(join(tmpdir(),'story-roster-loading-')); await mkdir(join(work,'empty-env'));
|
||||
const server=await createServer({
|
||||
configFile:false,root:resolve('web'),envDir:join(work,'empty-env'),cacheDir:join(work,'vite-cache'),
|
||||
oxc:{jsx:{runtime:'automatic'}},logLevel:'error',server:{host:'127.0.0.1',port:0},plugins:[{
|
||||
name:'subject-roster-fixture',
|
||||
configureServer(vite){vite.middlewares.use('/__roster-test',(_request,response)=>{
|
||||
response.setHeader('Content-Type','text/html');
|
||||
response.end('<!doctype html><meta charset="UTF-8"><div id="root"></div><script type="module" src="/@id/__roster-test.tsx"></script>');
|
||||
});},
|
||||
resolveId(id){if(id==='__roster-test.tsx')return '\0__roster-test.tsx';},
|
||||
load(id){if(id==='\0__roster-test.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';
|
||||
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('subjects');return React.createElement(SaaSWorkspace,{accounts,accessToken:'synthetic-only',section,onSectionChange:setSection,redesignedHome:true,showNavigation:false});}
|
||||
createRoot(document.getElementById('root')).render(React.createElement(App));
|
||||
`;},
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
const origin=`http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
const browser=await chromium.launch({headless:true,...(process.env.CHROME_EXECUTABLE?{executablePath:process.env.CHROME_EXECUTABLE}:{})});
|
||||
const context=await browser.newContext({viewport:{width:1165,height:747},serviceWorkers:'block'});
|
||||
await context.addInitScript(()=>{
|
||||
localStorage.setItem('companion:household:qa-tenant/qa-user','A');
|
||||
const originalFetch=window.fetch.bind(window);
|
||||
window.rosterResponses=[];
|
||||
window.fetch=async(input,init)=>{
|
||||
const url=new URL(typeof input==='string'?input:input.url,location.href);
|
||||
if(!/^\/api\/v1\/households\/[^/]+\/subjects$/.test(url.pathname)||(init?.method??'GET')!=='GET')return originalFetch(input,init);
|
||||
// Simulate a transport that still delivers an old read after cancellation.
|
||||
// The production effect's own AbortController/scope guards must reject it.
|
||||
const response=await originalFetch(input,{...init,signal:undefined});
|
||||
window.rosterResponses.push((await response.clone().json()).request_id);
|
||||
return response;
|
||||
};
|
||||
});
|
||||
const page=await context.newPage();page.setDefaultTimeout(15000);
|
||||
const errors=[],blocked=[],pending=[],checks=[];
|
||||
const held=new Set();let sequence=0;
|
||||
const capabilities=['household.read','household.manage','member.manage','ownership.transfer','subject.read','subject.write','privacy.read','privacy.manage','conversation.read','story.review'];
|
||||
const households=['A','B'].map(id=>({id,name:`合成家庭${id}`,tenant_id:'qa-tenant',status:'active',role:'owner',capabilities,version:1}));
|
||||
const subject=(id,suffix='')=>({id:`elder-${id}`,household_id:id,preferred_name:`合成长者${id}${suffix}`,language:'zh-CN',timezone:'Asia/Shanghai',profile_data:{background:'本地合成',interests:[]},conversation_preferences:{pace:'slow',conversation_goal:'日常陪伴',preferred_topics:[],avoided_topics:[]},status:'active',version:1});
|
||||
page.on('pageerror',error=>errors.push(error.message));
|
||||
await page.route('**/*',async route=>{
|
||||
const request=route.request(),url=new URL(request.url()),path=url.pathname,method=request.method();
|
||||
if(url.origin!==origin){blocked.push(url.origin+path);return route.abort('blockedbyclient');}
|
||||
if(!path.startsWith('/api/'))return route.continue();
|
||||
const answer=data=>route.fulfill({json:{ok:true,data,request_id:'synthetic-roster'},headers:{ETag:'"v1"'}});
|
||||
if(path==='/api/v1/events')return route.fulfill({contentType:'text/event-stream',body:': synthetic read-only fixture\n\n'});
|
||||
if(path==='/api/v1/households')return answer({items:households});
|
||||
const roster=path.match(/^\/api\/v1\/households\/([^/]+)\/subjects$/);
|
||||
if(roster&&method==='GET'){
|
||||
const id=roster[1],requestID=`roster-${++sequence}`;
|
||||
let result={items:[subject(id)]};
|
||||
if(held.has(id))result=await new Promise(resolve=>pending.push({id,requestID,release:resolve,released:false}));
|
||||
return result.error
|
||||
?route.fulfill({status:502,json:{ok:false,error:{code:'SYNTHETIC_UNAVAILABLE',message:'合成名单读取失败'},request_id:requestID}})
|
||||
:route.fulfill({json:{ok:true,data:{items:result.items},request_id:requestID}});
|
||||
}
|
||||
if(path.endsWith('/members'))return answer({items:[{id:'qa-user',user_id:'qa-user',display_name:'合成用户',role:'owner',status:'active',is_owner:true,version:1,joined_at:'2026-10-06T00:00:00Z'}]});
|
||||
if(path.endsWith('/conversation-readiness'))return answer({subject_id:'elder-A',ready:false,checks:[],devices:[],start_time_checks:[]});
|
||||
if(method==='GET'||(method==='POST'&&path.endsWith('/query')))return answer({items:[],next_cursor:''});
|
||||
errors.push(`Unexpected mutation ${method} ${path}`);return route.fulfill({status:400,json:{ok:false}});
|
||||
});
|
||||
const nextRead=async id=>{
|
||||
const deadline=Date.now()+10000;
|
||||
while(Date.now()<deadline){const job=pending.find(item=>item.id===id&&!item.taken);if(job){job.taken=true;return job;}await new Promise(resolve=>setTimeout(resolve,10));}
|
||||
throw new Error(`Missing synthetic held read for ${id}`);
|
||||
};
|
||||
const release=async(job,result)=>{
|
||||
job.released=true;job.release(result);
|
||||
await page.waitForFunction(id=>window.rosterResponses.includes(id),job.requestID);
|
||||
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
};
|
||||
const changeFamily=async id=>{
|
||||
await page.getByRole('button',{name:'切换家庭',exact:true}).click();
|
||||
await page.getByRole('menuitem',{name:`合成家庭${id}`,exact:true}).click();
|
||||
};
|
||||
const assertLoading=async()=>{
|
||||
await page.getByText('正在读取长者资料',{exact:true}).waitFor();
|
||||
const body=await page.locator('.cp-subject-page').innerText();
|
||||
assert.doesNotMatch(body,/为家人添加第一份资料|添加长者|合成长者[AB]/);
|
||||
};
|
||||
const shown=async name=>page.getByRole('heading',{name,exact:true}).waitFor();
|
||||
try{
|
||||
await page.goto(origin+'/__roster-test');await shown('合成长者A');
|
||||
await page.evaluate(()=>{
|
||||
window.emptyRosterFrames=[];window.watchRosterEmpty=true;
|
||||
const observer=new MutationObserver(()=>{
|
||||
if(window.watchRosterEmpty&&document.querySelector('.cp-subject-page')?.textContent.includes('为家人添加第一份资料'))window.emptyRosterFrames.push(document.querySelector('.cp-subject-page').textContent);
|
||||
});observer.observe(document.getElementById('root'),{childList:true,subtree:true,characterData:true});
|
||||
});
|
||||
held.add('B');await changeFamily('B');const delayedB=await nextRead('B');await assertLoading();
|
||||
await release(delayedB,{items:[subject('B')]});await shown('合成长者B');
|
||||
held.add('A');await changeFamily('A');const delayedA=await nextRead('A');await assertLoading();
|
||||
await release(delayedA,{items:[subject('A')]});await shown('合成长者A');
|
||||
checks.push('A → B and B → A delayed reads show loading, never first-profile creation guidance');
|
||||
|
||||
await changeFamily('B');const oldB=await nextRead('B');await assertLoading();
|
||||
await changeFamily('A');const middleA=await nextRead('A');await assertLoading();
|
||||
await changeFamily('B');const newestB=await nextRead('B');await assertLoading();
|
||||
await release(newestB,{items:[subject('B','最新')]});await shown('合成长者B最新');
|
||||
await release(middleA,{items:[]});await release(oldB,{items:[]});await shown('合成长者B最新');
|
||||
checks.push('B → A → B: newer B result survives older A and B empty responses delivered after abort');
|
||||
|
||||
await changeFamily('A');const oldA=await nextRead('A');await assertLoading();
|
||||
await changeFamily('B');const middleB=await nextRead('B');await assertLoading();
|
||||
await changeFamily('A');const newestA=await nextRead('A');await assertLoading();
|
||||
await release(newestA,{items:[subject('A','最新')]});await shown('合成长者A最新');
|
||||
await release(middleB,{items:[]});await release(oldA,{items:[]});await shown('合成长者A最新');
|
||||
assert.deepEqual(await page.evaluate(()=>window.emptyRosterFrames),[]);
|
||||
checks.push('A → B → A: newer A result survives older B and A empty responses; observer records zero false-empty renders');
|
||||
|
||||
await page.evaluate(()=>window.watchRosterEmpty=false);
|
||||
await changeFamily('B');const emptyB=await nextRead('B');await assertLoading();
|
||||
await release(emptyB,{items:[]});await page.getByText('为家人添加第一份资料',{exact:true}).waitFor();
|
||||
checks.push('first-profile guidance appears only after the current family read confirms an empty roster');
|
||||
await changeFamily('A');const failedA=await nextRead('A');await assertLoading();
|
||||
await release(failedA,{error:true});await page.getByText('长者资料暂时无法读取',{exact:true}).waitFor();
|
||||
assert.doesNotMatch(await page.locator('.cp-subject-page').innerText(),/为家人添加第一份资料|添加长者/);
|
||||
checks.push('a failed roster read is distinct from empty and does not suggest creating duplicate profiles');
|
||||
assert.deepEqual(errors,[]);assert.deepEqual(blocked,[]);
|
||||
console.log(JSON.stringify({ok:true,checks,falseEmptyRenders:0,blockedExternal:blocked},null,2));
|
||||
}finally{
|
||||
for(const job of pending)if(!job.released)job.release({items:[]});
|
||||
await browser.close();await server.close();
|
||||
if(dirname(work)!==resolve(tmpdir()))throw new Error('Unsafe fixture cleanup path');
|
||||
await rm(work,{recursive:true,force:true});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Real React UI, mocked Tencent SDK/API: no SMS or chargeable verification calls.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
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 server = await createServer({ configFile: false, root: resolve('web'), oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 5325, strictPort: true }, plugins: [{ name: 'captcha-test-harness', resolveId(id) { if (id === '/captcha-harness.jsx') return id; }, load(id) { if (id === '/captcha-harness.jsx') return `import '/src/styles.css';import '/src/maintenance-console.css';import React from 'react';import {createRoot} from 'react-dom/client';import {CaptchaPolicySettings} from '/src/components/CaptchaPolicySettings.tsx';createRoot(document.getElementById('root')).render(<CaptchaPolicySettings tenant="test-tenant" token="test-token"/>);`; } }] });
|
||||
await server.listen(); let browser, currentPage; const errors = [], checks = [];
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); currentPage = page; page.on('pageerror', e => errors.push(e.message));
|
||||
await page.route('https://turing.captcha.qcloud.com/TJCaptcha.js', r => r.fulfill({ contentType: 'text/javascript', body: `window.TencentCaptcha=class{constructor(id,cb){window.lastCaptchaApp=id;this.cb=cb;}show(){setTimeout(()=>this.cb(window.captchaResult||{ret:0,ticket:'web-ticket',randstr:'random'}),0)}destroy(){}}` }));
|
||||
let generation = 0, sends = 0, passwords = 0, mode = 'tencent';
|
||||
let expireNextCaptcha = true;
|
||||
let policy = { saas_mode: 'image', mini_mode: 'image', version: 1, saas_configured: true, mini_configured: false }, writes = 0;
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const req = route.request(), path = new URL(req.url()).pathname, body = req.method() === 'POST' ? req.postDataJSON() : undefined;
|
||||
let status = 200, code = 'OK', message = '', data = {};
|
||||
if (path.endsWith('/account/session')) { status = 401; code = 'LOGIN_REQUIRED'; }
|
||||
else if (path.endsWith('/login/session')) data = { sms_enabled: true, csrf_token: 'login-csrf', password_enabled: true };
|
||||
else if (path.endsWith('/login/captcha')) { data = { captcha_id: 'captcha-' + ++generation, mode, captcha_app_id: mode === 'tencent' ? '123' : undefined, image: mode === 'image' ? 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' : undefined, expires_at_ms: Date.now() + (expireNextCaptcha ? -1000 : 120000) }; expireNextCaptcha = false; }
|
||||
else if (path.endsWith('/sms/send')) { assert.equal(body.captcha_ticket, 'web-ticket'); assert.equal(body.captcha_randstr, 'random'); assert.equal(body.captcha_code, undefined); assert.equal(body.captcha_id, 'captcha-' + generation); sends++; data = { sms_challenge_id: 'sms', expires_at_ms: Date.now() + 300000, resend_after_ms: 60000 }; }
|
||||
else if (path.endsWith('/login/password')) { assert.equal(body.captcha_ticket, 'web-ticket'); assert.equal(body.captcha_randstr, 'random'); passwords++; status = 401; code = 'PASSWORD_INVALID'; message = '手机号或密码不正确'; }
|
||||
else if (path === '/api/v1/ops/login-captcha-policy') {
|
||||
assert.equal(req.headers()['x-tenant-id'], 'test-tenant'); assert.equal(req.headers().authorization, 'Bearer test-token');
|
||||
if (body) { assert.equal(body.version, policy.version); assert.equal(body.saas_mode, 'tencent'); assert.equal(body.mini_mode, 'image'); writes++; policy = { ...policy, ...body, version: policy.version + 1 }; }
|
||||
data = policy;
|
||||
} else throw new Error('Unexpected API ' + path);
|
||||
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ ok: status === 200, code, message, data }) });
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5325/'); await page.getByLabel('手机号', { exact: true }).waitFor();
|
||||
assert.equal(await page.locator('#customer-image-code').count(), 0); await page.getByLabel('手机号', { exact: true }).fill('13800000000');
|
||||
assert.equal(await page.getByText('安全验证', { exact: true }).count(), 0);
|
||||
assert.equal(await page.getByRole('button', { name: '刷新验证', exact: true }).count(), 0);
|
||||
assert.equal(await page.evaluate(() => !!window.TencentCaptcha), false);
|
||||
const beforeExpiredRetry = generation;
|
||||
await page.evaluate(() => { window.captchaResult = { ret: 2 }; }); await page.getByRole('button', { name: '获取验证码', exact: true }).click(); await page.getByText('已取消验证', { exact: true }).waitFor(); assert.equal(sends, 0);
|
||||
assert(generation > beforeExpiredRetry, 'Expired hidden challenge is renewed when send is clicked');
|
||||
checks.push('no security panel or SDK on initial render; clicking send renews an expired challenge and opens Tencent verification');
|
||||
await page.evaluate(() => { window.captchaResult = { ret: 0, ticket: 'trerror_123', randstr: 'random' }; }); await page.getByRole('button', { name: '获取验证码', exact: true }).click(); await page.getByText('人机验证未通过,请重试', { exact: true }).waitFor(); assert.equal(sends, 0);
|
||||
await page.evaluate(() => { window.captchaResult = undefined; }); await page.getByRole('button', { name: '获取验证码', exact: true }).click(); await page.getByText(/验证码已发送/).waitFor(); assert.equal(sends, 1); assert.equal(await page.evaluate(() => window.lastCaptchaApp), '123');
|
||||
checks.push('SaaS SDK ticket submitted only after successful verification; cancel and disaster tickets cannot send SMS');
|
||||
await page.getByRole('button', { name: '密码登录', exact: true }).click(); await page.getByLabel('密码', { exact: true }).fill('WrongPassword123!'); await page.locator('.sms-login-form').getByRole('button', { name: /^登\s*录$/ }).click(); await page.getByText('手机号或密码不正确', { exact: true }).waitFor(); assert.equal(passwords, 1);
|
||||
checks.push('password login uses Tencent proof and refreshes after failure');
|
||||
assert.equal(await page.getByRole('button', { name: '忘记密码', exact: true }).count(), 1);
|
||||
mode = 'image'; await page.reload(); await page.getByLabel('图片验证码', { exact: true }).waitFor();
|
||||
checks.push('server policy switch returns numeric image UI without changing the client');
|
||||
await page.route('**/__captcha-settings', r => r.fulfill({ contentType: 'text/html', body: '<div id="root"></div><script type="module" src="/captcha-harness.jsx"></script>' }));
|
||||
await page.goto('http://127.0.0.1:5325/__captcha-settings'); await page.getByText('登录与短信安全验证', { exact: true }).waitFor();
|
||||
const web = page.getByRole('radiogroup', { name: 'SaaS / 管理端验证码方式' }), mini = page.getByRole('radiogroup', { name: '微信小程序验证码方式' });
|
||||
assert(await mini.getByRole('radio', { name: /^腾讯云验证码/ }).isDisabled());
|
||||
await web.getByRole('radio', { name: /^腾讯云验证码/ }).check(); await page.getByRole('button', { name: '保存验证码设置' }).click(); await page.getByText(/已保存。新验证立即生效/).waitFor(); assert.equal(writes, 1);
|
||||
await page.reload(); await page.getByText('登录与短信安全验证', { exact: true }).waitFor(); assert(await web.getByRole('radio', { name: /^腾讯云验证码/ }).isChecked());
|
||||
await mkdir(resolve('.design-evidence/tencent-captcha'), { recursive: true }); await page.screenshot({ path: resolve('.design-evidence/tencent-captcha/settings.png'), fullPage: true });
|
||||
await page.setViewportSize({ width: 375, height: 850 }); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
checks.push('admin settings keep clients independent, disable unconfigured Tencent mode, save and reload; mobile layout has no horizontal overflow');
|
||||
assert.deepEqual(errors, []); console.log(JSON.stringify({ checks, errors }));
|
||||
} catch (error) { console.error((await currentPage?.locator('body').innerText())?.slice(0,2000)); console.error(await currentPage?.locator('button').evaluateAll(items=>items.map(x=>({text:x.textContent,html:x.outerHTML.slice(0,800)})))); console.error(await currentPage?.evaluate(()=>({sdk:window.TencentCaptcha?.toString().slice(0,150),result:window.captchaResult,body:document.body.getAttribute('aria-hidden')}))); console.error(errors); throw error; }
|
||||
finally { await browser?.close(); await server.close(); }
|
||||
@@ -0,0 +1,189 @@
|
||||
// Actual portal components and routing, deterministic dates and synthetic API
|
||||
// metadata only. Backend aggregation is tested separately against local PG.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve, join, dirname } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { installCustomerFixture, households, subjects, devices, 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 work=await mkdtemp(join(tmpdir(),'story-usage-boundaries-'));
|
||||
const output=resolve(process.env.USAGE_BOUNDARY_OUTPUT ?? '../.local-evidence/usage-chat-boundaries-20261006/browser');
|
||||
await mkdir(join(work,'empty-env')); await mkdir(output,{recursive:true});
|
||||
const server=await createServer({configFile:false,root:resolve('web'),envDir:join(work,'empty-env'),cacheDir:join(work,'vite-cache'),oxc:{jsx:{runtime:'automatic'}},logLevel:'error',server:{host:'127.0.0.1',port:0},plugins:[{
|
||||
name:'usage-boundary-fixture',
|
||||
configureServer(vite){vite.middlewares.use('/__usage-boundary',(_q,response)=>{response.setHeader('Content-Type','text/html');response.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/__usage-boundary.tsx"></script>');});},
|
||||
resolveId(name){if(name==='__usage-boundary.tsx')return '\0__usage-boundary.tsx';},
|
||||
load(name){if(name==='\0__usage-boundary.tsx')return `import React from 'react';import {createRoot} from 'react-dom/client';import {AuthProvider} from '/src/auth/useAuth.ts';import {CustomerPortal} from '/src/pages/CustomerPortal.tsx';import '/src/styles.css';import '/src/cal-theme.css';import '/src/customer-responsive.css';createRoot(document.getElementById('root')).render(React.createElement(AuthProvider,null,React.createElement(CustomerPortal)));`;},
|
||||
}]});
|
||||
const now='2026-10-06T04:00:00.000Z', familyA=households[0],familyB=households[1];
|
||||
const roster=[...subjects.slice(0,2),{...subjects[2],id:id(410),household_id:familyB.id,preferred_name:'合成空家庭长者'}];
|
||||
const fleet=devices.slice(0,2).map((d,index)=>({...d,model:`合成设备${index+1}`}));
|
||||
const iso=ms=>new Date(ms).toISOString();
|
||||
const beijingDay=at=>iso(Date.parse(at)+8*3600000).slice(0,10);
|
||||
const mainStarts={30:'2026-09-06T16:00:00.000Z',90:'2026-07-08T16:00:00.000Z',365:'2025-10-06T16:00:00.000Z'};
|
||||
const usageRows=[];
|
||||
function addUsage(at,deviceIndex){const device=fleet[deviceIndex],duration=(deviceIndex+1)*60000;usageRows.push({id:id(2100+usageRows.length),household_id:familyA.id,device_id:device.id,subject_name:device.subject_name,device_name:device.model,started_at:at,ended_at:iso(Date.parse(at)+duration),duration_ms:duration,status:'COMPLETED'});}
|
||||
addUsage('2026-10-05T15:59:59.999Z',0);addUsage('2026-10-05T16:00:00.000Z',1);
|
||||
for(let i=0;i<23;i++)addUsage(iso(Date.parse('2026-10-05T10:00:00Z')-i*60000),i%2);
|
||||
for(const [days,deviceIndex] of [[30,0],[90,1],[365,0]]){addUsage(iso(Date.parse(mainStarts[days])-1),1-deviceIndex);addUsage(mainStarts[days],deviceIndex);}
|
||||
addUsage(now,0);addUsage(iso(Date.parse(now)+1),1);
|
||||
const legacyRows=[];
|
||||
function addLegacy(at,subjectID=subjects[0].id){legacyRows.push({id:id(3000+legacyRows.length),subject_id:subjectID,device_id:fleet[0].id,created_at:at,updated_at:at,started_at:at,ended_at:iso(Date.parse(at)+60000),duration_ms:60000,status:'FAILED',engine_mode:'s2s_hybrid',runtime_type:'legacy_voicechat',retention_mode:'none'});}
|
||||
for(let i=0;i<35;i++)addLegacy(iso(Date.parse('2026-10-06T03:00:00Z')-i*60000));
|
||||
for(const at of ['2026-10-05T15:59:59.999Z','2026-10-05T16:00:00.000Z','2026-10-06T15:59:59.999Z','2026-10-06T16:00:00.000Z'])addLegacy(at);
|
||||
for(let i=0;i<16;i++)addLegacy(iso(Date.parse('2026-10-06T02:00:00Z')-i*60000),subjects[1].id);
|
||||
const modernRows=roster.flatMap((s,index)=>Array.from({length:index===0?55:index===1?21:0},(_,i)=>({id:id(4000+index*100+i),elder_id:s.id,business_session_id:id(4000+index*100+i),created_at:iso(Date.parse('2026-10-06T03:00:00Z')-i*60000),status:'ENDED',transcript_state:'EMPTY',duration_ms:60000})));
|
||||
const reads=[],mutations=[],external=[],textRequests=[],errors=[],results=[];
|
||||
let browser,origin;
|
||||
function aggregate(household,params){
|
||||
const deviceID=params.get('device_id'),from=Date.parse(params.get('from')),to=Date.parse(params.get('to'));
|
||||
if(deviceID)assert(fleet.some(d=>d.id===deviceID&&d.household_id===household),'foreign device reached usage request');
|
||||
const rows=usageRows.filter(r=>r.household_id===household&&(!deviceID||r.device_id===deviceID)&&Date.parse(r.started_at)>=from&&Date.parse(r.started_at)<to).sort((a,b)=>b.started_at.localeCompare(a.started_at));
|
||||
const daily=new Map(),byDevice=new Map();
|
||||
for(const r of rows){const date=beijingDay(r.started_at),d=daily.get(date)??{date,duration_ms:0,conversation_count:0,cost_micros:0};d.duration_ms+=r.duration_ms;d.conversation_count++;daily.set(date,d);const item=byDevice.get(r.device_id)??{id:r.device_id,name:r.device_name,conversation_count:0,duration_ms:0,cost_micros:0};item.duration_ms+=r.duration_ms;item.conversation_count++;byDevice.set(r.device_id,item);}
|
||||
const total=rows.reduce((sum,r)=>sum+r.duration_ms,0);
|
||||
return{from:params.get('from'),to:params.get('to'),timezone:'Asia/Shanghai',scope:{household_id:household,household_name:households.find(h=>h.id===household).name,...(deviceID?{device_id:deviceID}:{})},total_duration_ms:total,average_duration_ms:rows.length?Math.floor(total/rows.length):0,conversation_count:rows.length,daily:[...daily.values()].sort((a,b)=>a.date.localeCompare(b.date)),devices:[...byDevice.values()],recent_conversations:rows.slice(0,20),currency:'CNY',cost_available:false};
|
||||
}
|
||||
async function start(width,section='billing',instant=now,timezoneId='America/Los_Angeles'){
|
||||
const context=await browser.newContext({viewport:{width,height:844},timezoneId,serviceWorkers:'block'});
|
||||
const unexpected=await installCustomerFixture(context);
|
||||
const page=await context.newPage();page.setDefaultTimeout(10000);await page.clock.setFixedTime(new Date(instant));page.on('pageerror',e=>errors.push(e.message));
|
||||
const control={holdLegacyMore:false,holdUsage:false,pending:[],usageAnswers:[]};
|
||||
await page.route('**/*',async route=>{
|
||||
const request=route.request(),url=new URL(request.url()),path=url.pathname;
|
||||
if(url.origin!==origin){external.push(url.origin+path);return route.abort();}
|
||||
if(!path.startsWith('/api/'))return route.continue();
|
||||
const answer=data=>route.fulfill({json:{data,request_id:'synthetic-boundary'}});
|
||||
if(path==='/api/v1/memories/calls/query'&&request.method()==='POST'){
|
||||
const body=request.postDataJSON(),offset=body.cursor?Number(body.cursor.split(':').at(-1)):0;
|
||||
reads.push({kind:'modern',...body});const all=modernRows.filter(r=>r.elder_id===body.elder_id),limit=body.limit;
|
||||
return answer({items:all.slice(offset,offset+limit),next_cursor:all.length>offset+limit?`${body.elder_id}:${offset+limit}`:''});
|
||||
}
|
||||
if(/\/(turns|summaries)$|\/calls\/turns$/.test(path)){textRequests.push(path);return route.abort();}
|
||||
if(request.method()!=='GET'){mutations.push(request.method()+' '+path);return route.abort();}
|
||||
if(path==='/api/v1/devices')return answer({items:fleet});
|
||||
const subjectMatch=path.match(/\/households\/([^/]+)\/subjects$/);
|
||||
if(subjectMatch)return answer({items:roster.filter(s=>s.household_id===subjectMatch[1])});
|
||||
const usage=path.match(/\/households\/([^/]+)\/usage-overview$/);
|
||||
if(usage){
|
||||
reads.push({kind:'usage',householdID:usage[1],...Object.fromEntries(url.searchParams)});
|
||||
const result=aggregate(usage[1],url.searchParams);control.usageAnswers.push(result);
|
||||
if(control.holdUsage){control.holdUsage=false;await new Promise(release=>control.pending.push(release));}
|
||||
return answer(result);
|
||||
}
|
||||
const legacy=path.match(/\/subjects\/([^/]+)\/conversations$/);
|
||||
if(legacy){
|
||||
const params=url.searchParams,offset=params.has('cursor')?Number(params.get('cursor').split(':').at(-1)):0,limit=Number(params.get('limit'));
|
||||
reads.push({kind:'legacy',subjectID:legacy[1],...Object.fromEntries(params)});
|
||||
const all=legacyRows.filter(r=>r.subject_id===legacy[1]&&(!params.has('from')||Date.parse(r.created_at)>=Date.parse(params.get('from')))&&(!params.has('to')||Date.parse(r.created_at)<Date.parse(params.get('to')))&&(!params.has('status')||r.status===params.get('status'))&&(!params.has('engine_mode')||r.engine_mode===params.get('engine_mode'))).sort((a,b)=>b.created_at.localeCompare(a.created_at));
|
||||
if(control.holdLegacyMore&¶ms.has('cursor')){control.holdLegacyMore=false;await new Promise(release=>control.pending.push(release));}
|
||||
return answer({items:all.slice(offset,offset+limit),next_cursor:all.length>offset+limit?`${legacy[1]}:${offset+limit}`:''});
|
||||
}
|
||||
if(/\/(stories|memories|facts|events\/history)$/.test(path))return answer({items:[]});
|
||||
if(path.endsWith('/home-overview'))return answer({daily:[],conversation_count:0,total_duration_ms:0,week_start:'2026-10-05',week_end:'2026-10-12'});
|
||||
return route.fallback();
|
||||
});
|
||||
await page.goto(origin+'/__usage-boundary?section='+section);
|
||||
return{page,context,control,unexpected};
|
||||
}
|
||||
const usageDevice=page=>page.locator('.cp-usage-filters label select');
|
||||
const usagePeriod=page=>page.getByRole('combobox',{name:'用量时间范围',exact:true});
|
||||
async function usageReady(page,count){await page.locator('.cp-usage-metrics').getByText(String(count)+' 次',{exact:true}).waitFor();}
|
||||
async function firstPage(page,label){await page.locator(`[aria-label="${label}第 1 页"][aria-current="page"]`).waitFor({state:'attached'});}
|
||||
async function openEarly(page){
|
||||
// Wait for the initial response before opening this native details element.
|
||||
await page.locator('.conversation-filter__summary').getByText(/^已加载 20 条/).waitFor({state:'attached'});
|
||||
const summary=page.locator('summary').filter({hasText:'查看早期聊天记录'});
|
||||
if(!await summary.evaluate(element=>element.parentElement.open))await summary.click();
|
||||
await page.getByRole('navigation',{name:'聊天时间线分页',exact:true}).waitFor();
|
||||
}
|
||||
async function nav(page,label,width){if(width<900)await page.getByRole('button',{name:'打开导航菜单',exact:true}).click();await page.getByRole('link',{name:label,exact:true}).click();}
|
||||
async function switchFamily(page,width){if(width<900)await page.getByRole('button',{name:'打开导航菜单',exact:true}).click();await page.getByRole('button',{name:'切换家庭',exact:true}).click();await page.getByRole('menuitem',{name:familyB.name,exact:true}).click();if(width<900)await page.locator('.ch-mobile-drawer .ant-drawer-close').click();}
|
||||
async function check(name,run){try{await run();results.push({name,status:'passed'});}catch(error){results.push({name,status:'failed',error:error.message});}console.log(JSON.stringify(results.at(-1)));}
|
||||
async function withPage(width,section,run,instant=now,timezone){const state=await start(width,section,instant,timezone);try{await run(state);}catch(error){await state.page.screenshot({path:join(output,`failure-${results.length}-${width}.png`)});await writeFile(join(output,`failure-${results.length}-${width}.txt`),await state.page.locator('body').innerText());throw error;}finally{state.control.pending.splice(0).forEach(release=>release());await state.context.close();assert.deepEqual(state.unexpected,[]);}}
|
||||
try{
|
||||
await server.listen();origin=`http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
browser=await chromium.launch({headless:true,...(process.env.CHROME_EXECUTABLE?{executablePath:process.env.CHROME_EXECUTABLE}:{})});
|
||||
for(const width of [1188,389]){
|
||||
await check(`${width}px usage: 30/90/365 boundaries, two independent devices and all-device totals, paging/filter reset`,()=>withPage(width,'billing',async({page,control})=>{
|
||||
await usageReady(page,26);assert.match(await page.locator('.cp-usage-metrics').innerText(),/38\s*分钟/);
|
||||
assert.match(await page.locator('.cp-table tbody tr').nth(0).innerText(),/10\/6.*00:00/);
|
||||
assert.match(await page.locator('.cp-table tbody tr').nth(1).innerText(),/10\/5.*23:59/);
|
||||
await page.getByRole('button',{name:'最近通话下一页',exact:true}).click();
|
||||
assert.equal(await page.locator('.cp-table tbody tr').count(),10);
|
||||
await usageDevice(page).selectOption(fleet[0].id);await usageReady(page,14);await firstPage(page,'最近通话');
|
||||
assert.match(await page.locator('.cp-usage-metrics').innerText(),/14\s*分钟/);
|
||||
assert((await page.locator('.cp-table tbody').innerText()).includes('合成设备1'));assert(!(await page.locator('.cp-table tbody').innerText()).includes('合成设备2'));
|
||||
await page.getByRole('button',{name:'最近通话下一页',exact:true}).click();assert.equal(await page.locator('.cp-table tbody tr').count(),4);
|
||||
await usageDevice(page).selectOption(fleet[1].id);await usageReady(page,12);await firstPage(page,'最近通话');
|
||||
assert.match(await page.locator('.cp-usage-metrics').innerText(),/24\s*分钟/);
|
||||
await usageDevice(page).selectOption('');await usageReady(page,26);await firstPage(page,'最近通话');
|
||||
const answer=control.usageAnswers.at(-1);assert.equal(answer.total_duration_ms,2280000);assert.equal(answer.devices.length,2);assert.equal(answer.recent_conversations.length,20);
|
||||
assert.equal(answer.daily.find(d=>d.date==='2026-10-06').conversation_count,1,'exact midnight starts the new Beijing date');
|
||||
assert.equal(answer.daily.find(d=>d.date==='2026-10-05').conversation_count,24,'pre-midnight session belongs to previous date');
|
||||
const periodCounts={30:26,90:28,365:30};
|
||||
for(const days of [90,365,30]){await page.getByRole('button',{name:'最近通话下一页',exact:true}).click();await usagePeriod(page).selectOption(String(days));await usageReady(page,periodCounts[days]);await firstPage(page,'最近通话');const request=reads.filter(r=>r.kind==='usage').at(-1);assert.equal(request.from,mainStarts[days]);assert.equal(request.to,now);assert.equal(request.timezone,'Asia/Shanghai');}
|
||||
await page.getByRole('combobox',{name:'最近通话每页条数',exact:true}).selectOption('5');await firstPage(page,'最近通话');assert.equal(await page.locator('.cp-table tbody tr').count(),5);
|
||||
if(width>=640){await page.getByRole('textbox',{name:'最近通话跳转页码',exact:true}).fill('4');await page.getByRole('button',{name:'最近通话跳转',exact:true}).click();}
|
||||
else for(let i=0;i<3;i++)await page.getByRole('button',{name:'最近通话下一页',exact:true}).click();
|
||||
assert.equal(await page.locator('[aria-current=page][aria-label="最近通话第 4 页"]').count(),1);
|
||||
await usagePeriod(page).selectOption('90');await usageReady(page,28);await firstPage(page,'最近通话');
|
||||
await page.screenshot({path:join(output,`usage-boundaries-${width}.png`)});
|
||||
}));
|
||||
await check(`${width}px usage: empty family clears selected device/results and ignores a delayed old-family response`,()=>withPage(width,'billing',async({page,control})=>{
|
||||
await usageReady(page,26);await usageDevice(page).selectOption(fleet[0].id);await usageReady(page,14);
|
||||
control.holdUsage=true;await page.getByRole('button',{name:'刷新',exact:true}).click();await page.waitForFunction(()=>document.querySelector('.cp-usage-metrics')?.textContent.includes('—'));
|
||||
await switchFamily(page,width);await usageReady(page,0);assert.equal(await usageDevice(page).inputValue(),'');assert.equal(await usageDevice(page).locator('option').count(),1);
|
||||
control.pending.splice(0).forEach(release=>release());await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
await usageReady(page,0);assert.equal(await page.locator('.cp-table tbody tr').count(),0);assert.equal(await page.getByRole('group',{name:'每日聊天用量',exact:true}).count(),0);
|
||||
assert(!reads.filter(r=>r.kind==='usage'&&r.householdID===familyB.id).some(r=>r.device_id));await page.screenshot({path:join(output,`empty-family-${width}.png`)});
|
||||
}));
|
||||
await check(`${width}px device chats: API cursor, local page/size, elder switch, refresh and empty family reset`,()=>withPage(width,'conversations',async({page})=>{
|
||||
const pager=page.getByRole('navigation',{name:'聊天记录分页',exact:true});await pager.getByText('30',{exact:true}).waitFor();assert.equal(await page.locator('.cp-memory-row').count(),10);
|
||||
await pager.getByRole('button',{name:'聊天记录下一页',exact:true}).click();await page.getByRole('combobox',{name:'聊天记录每页条数',exact:true}).selectOption('5');await firstPage(page,'聊天记录');assert.equal(await page.locator('.cp-memory-row').count(),5);
|
||||
await pager.getByRole('button',{name:/^读取后续记录/}).click();await pager.getByText('55',{exact:true}).waitFor();assert.equal(await pager.getByRole('button',{name:/^读取后续记录/}).count(),0);
|
||||
await pager.getByRole('button',{name:'聊天记录下一页',exact:true}).click();await page.locator('.cp-subject-picker .cp-elder-roster button').filter({hasText:subjects[1].preferred_name}).click();await pager.getByText('21',{exact:true}).waitFor();await firstPage(page,'聊天记录');
|
||||
await pager.getByRole('button',{name:'聊天记录下一页',exact:true}).click();await page.getByRole('button',{name:'刷新记录',exact:true}).click();await firstPage(page,'聊天记录');
|
||||
await switchFamily(page,width);await page.locator('.cp-elder-roster button[aria-pressed=true]').filter({hasText:roster[2].preferred_name}).waitFor();await page.locator('section[aria-label="设备聊天记录"]').getByRole('heading',{name:'还没有聊天记录',exact:true}).waitFor();assert.equal(await page.locator('.cp-memory-row').count(),0);assert.equal(await pager.count(),0);
|
||||
}));
|
||||
await check(`${width}px early chats: inclusive Beijing date inputs, exclusive next midnight, paging/filter reset`,()=>withPage(width,'conversations',async({page})=>{
|
||||
await openEarly(page);const pager=page.getByRole('navigation',{name:'聊天时间线分页',exact:true});await pager.getByText('20',{exact:true}).waitFor();
|
||||
await pager.getByRole('button',{name:/^读取后续记录/}).click();await pager.getByText('39',{exact:true}).waitFor();await pager.getByRole('button',{name:'聊天时间线下一页',exact:true}).click();
|
||||
await page.getByRole('button',{name:'筛选日期与状态',exact:true}).click();const dates=page.locator('.conversation-filter input[type=date]');await dates.nth(0).fill('2026-10-06');await dates.nth(1).fill('2026-10-06');await pager.getByText('20',{exact:true}).waitFor();await firstPage(page,'聊天时间线');
|
||||
const query=reads.filter(r=>r.kind==='legacy').at(-1);assert.equal(query.from,'2026-10-05T16:00:00.000Z');assert.equal(query.to,'2026-10-06T16:00:00.000Z');assert(!query.cursor);
|
||||
await pager.getByRole('button',{name:/^读取后续记录/}).click();await pager.getByText('37',{exact:true}).waitFor();
|
||||
const expected=legacyRows.filter(r=>r.subject_id===subjects[0].id&&r.created_at>=query.from&&r.created_at<query.to);assert.equal(expected.length,37);assert(!expected.some(r=>r.created_at==='2026-10-06T16:00:00.000Z'));
|
||||
await pager.getByRole('button',{name:'聊天时间线下一页',exact:true}).click();await page.locator('.conversation-filter').getByLabel(/^通话状态/).selectOption('ACTIVE');await page.locator('.conversation-filter__summary').getByText('已加载 0 条',{exact:true}).waitFor();assert.equal(await page.locator('.conversation-row').count(),0);
|
||||
await page.getByRole('button',{name:'清除筛选',exact:true}).click();await pager.getByText('20',{exact:true}).waitFor();await firstPage(page,'聊天时间线');assert.deepEqual(await dates.evaluateAll(nodes=>nodes.map(n=>n.value)),['','']);
|
||||
await page.screenshot({path:join(output,`early-chat-dates-${width}.png`)});
|
||||
}));
|
||||
await check(`${width}px early chats: delayed cursor page cannot leak into an empty different family`,()=>withPage(width,'conversations',async({page,control})=>{
|
||||
await openEarly(page);const pager=page.getByRole('navigation',{name:'聊天时间线分页',exact:true});await pager.getByText('20',{exact:true}).waitFor();
|
||||
control.holdLegacyMore=true;await pager.getByRole('button',{name:/^读取后续记录/}).click();
|
||||
await page.waitForFunction(()=>document.querySelector('nav[aria-label="聊天时间线分页"]')?.getAttribute('aria-busy')==='true');assert.equal(control.pending.length,1);
|
||||
await switchFamily(page,width);await page.locator('.cp-elder-roster button[aria-pressed=true]').filter({hasText:roster[2].preferred_name}).waitFor();await page.locator('.conversation-filter__summary').getByText('已加载 0 条',{exact:true}).waitFor({state:'attached'});
|
||||
const delivered=page.waitForResponse(response=>/\/subjects\/[^/]+\/conversations/.test(response.url())&&new URL(response.url()).searchParams.has('cursor'));
|
||||
control.pending.splice(0).forEach(release=>release());await (await delivered).finished();
|
||||
await page.waitForFunction(()=>!document.querySelector('.conversation-filter select:disabled'));
|
||||
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
assert.equal(await page.locator('.conversation-row').count(),0,'old-family cursor response revived conversation rows');
|
||||
assert.match(await page.locator('.conversation-filter__summary').innerText(),/已加载 0 条/);
|
||||
}));
|
||||
}
|
||||
for(const [instant,starts] of [
|
||||
['2026-10-05T15:59:59.999Z',{30:'2026-09-05T16:00:00.000Z',90:'2026-07-07T16:00:00.000Z',365:'2025-10-05T16:00:00.000Z'}],
|
||||
['2026-10-05T16:00:00.000Z',mainStarts],['2026-10-05T16:00:00.001Z',mainStarts],
|
||||
])await check(`Beijing midnight ${instant}: actual 30/90/365 request bounds in UTC browser`,()=>withPage(1188,'billing',async({page,control})=>{
|
||||
for(const days of [30,90,365]){await usagePeriod(page).waitFor();if(days!==30)await usagePeriod(page).selectOption(String(days));await page.waitForFunction(()=>!document.querySelector('.cp-usage-filters button')?.disabled);const response=control.usageAnswers.at(-1);assert.equal(response.from,starts[days]);assert.equal(response.to,instant);assert.equal(response.timezone,'Asia/Shanghai');}
|
||||
},instant,'Etc/UTC'));
|
||||
assert.deepEqual(mutations,[]);assert.deepEqual(external,[]);assert.deepEqual(textRequests,[]);assert.deepEqual(errors,[]);
|
||||
}finally{
|
||||
const result={passed:results.every(r=>r.status==='passed'),results,reads,mutations,external,textRequests,browserErrors:errors,isolation:{fixedSyntheticDates:true,freshEmptyViteEnv:true,productionRequests:0,businessMutations:0},scope:'Browser fixtures exercise UI and request wiring; actual backend SQL is covered by the separate Go/PostgreSQL test.'};
|
||||
await writeFile(join(output,'checks.json'),JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify({...result,reads:reads.length},null,2));
|
||||
await browser?.close();await server.close();assert.equal(dirname(work),resolve(tmpdir()));await rm(work,{recursive:true,force:true});
|
||||
}
|
||||
if(results.some(r=>r.status==='failed'))process.exitCode=1;
|
||||
@@ -0,0 +1,150 @@
|
||||
// Actual portal and routing. Only synthetic metadata is returned; conversation
|
||||
// text, external requests, and business mutations are blocked.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { installCustomerFixture, households, subjects, devices, 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 work = await mkdtemp(join(tmpdir(), 'story-usage-chat-'));
|
||||
const output = resolve(process.env.USAGE_CHAT_OUTPUT ?? '../.local-evidence/usage-chat-context-20261006');
|
||||
await mkdir(join(work, 'empty-env')); await mkdir(output, { recursive: true });
|
||||
const server = await createServer({ configFile: false, root: resolve('web'), envDir: join(work, 'empty-env'), cacheDir: join(work, 'vite-cache'), oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 }, plugins: [{
|
||||
name: 'usage-chat-fixture',
|
||||
configureServer(vite) { vite.middlewares.use('/__usage-context', (_q, response) => { response.setHeader('Content-Type', 'text/html'); response.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/__usage-context.tsx"></script>'); }); },
|
||||
resolveId(name) { if (name === '__usage-context.tsx') return '\0__usage-context.tsx'; },
|
||||
load(name) { if (name === '\0__usage-context.tsx') return `import React from 'react';import {createRoot} from 'react-dom/client';import {AuthProvider} from '/src/auth/useAuth.ts';import {CustomerPortal} from '/src/pages/CustomerPortal.tsx';import '/src/styles.css';import '/src/cal-theme.css';import '/src/customer-responsive.css';createRoot(document.getElementById('root')).render(React.createElement(AuthProvider,null,React.createElement(CustomerPortal)));`; },
|
||||
}] });
|
||||
const familyA = households[0], familyB = households[1];
|
||||
const roster = [...subjects.slice(0, 2), { ...subjects[2], id: id(410), household_id: familyB.id, preferred_name: '合成家庭乙长者' }];
|
||||
const fleet = [...devices.slice(0, 2), { ...devices[2], id: id(520), household_id: familyB.id, subject_id: id(410), subject_name: roster[2].preferred_name }, { ...devices[0], id: id(530), subject_id: undefined, subject_name: '尚未绑定' }];
|
||||
const reads = [], mutations = [], external = [], textRequests = [], errors = [], checks = [];
|
||||
let browser, origin;
|
||||
async function start(viewport, query = '?section=billing') {
|
||||
const context = await browser.newContext({ viewport, serviceWorkers: 'block' });
|
||||
const unexpected = await installCustomerFixture(context);
|
||||
const page = await context.newPage(); page.setDefaultTimeout(10000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.route('**/*', async route => {
|
||||
const request = route.request(), url = new URL(request.url()), path = url.pathname;
|
||||
if (url.origin !== origin) { external.push(url.origin + path); return route.abort(); }
|
||||
if (!path.startsWith('/api/')) return route.continue();
|
||||
const answer = data => route.fulfill({ json: { data, request_id: 'synthetic-usage' } });
|
||||
if (path === '/api/v1/memories/calls/query' && request.method() === 'POST') {
|
||||
const body = request.postDataJSON(); reads.push({ kind: 'calls', ...body });
|
||||
const count = body.elder_id === subjects[1].id ? 13 : body.elder_id === id(410) ? 2 : 0;
|
||||
return answer({ next_cursor: '', items: Array.from({ length: count }, (_, i) => ({ id: id(1600 + i), elder_id: body.elder_id, business_session_id: id(1600 + i), created_at: new Date(Date.now() - i * 86400000).toISOString(), status: 'ENDED', transcript_state: 'EMPTY', duration_ms: i ? 70000 : 66000 })) });
|
||||
}
|
||||
if (/\/calls\/turns$|\/turns$|\/summaries$/.test(path)) { textRequests.push(path); return route.abort(); }
|
||||
if (request.method() !== 'GET') { mutations.push(`${request.method()} ${path}`); return route.abort(); }
|
||||
if (path === '/api/v1/devices') return answer({ items: fleet });
|
||||
const householdSubjects = path.match(/\/households\/([^/]+)\/subjects$/);
|
||||
if (householdSubjects) { reads.push({ kind: 'roster', householdID: householdSubjects[1] }); return answer({ items: roster.filter(s => s.household_id === householdSubjects[1]) }); }
|
||||
const usage = path.match(/\/households\/([^/]+)\/usage-overview$/);
|
||||
if (usage) {
|
||||
const deviceID = url.searchParams.get('device_id'); reads.push({ kind: 'usage', householdID: usage[1], deviceID });
|
||||
const owner = fleet.find(d => d.id === deviceID && d.household_id === usage[1]);
|
||||
if (deviceID && !owner) throw new Error('Cross-family device leaked into a usage request');
|
||||
const count = owner?.subject_id === subjects[1].id ? 13 : 0;
|
||||
const recent = Array.from({ length: count }, (_, i) => ({ id: id(1600 + i), started_at: new Date(Date.now() - i * 86400000).toISOString(), ended_at: new Date(Date.now() - i * 86400000 + (i ? 70000 : 66000)).toISOString(), subject_name: owner.subject_name, device_name: owner.model, duration_ms: i ? 70000 : 66000, status: 'ENDED' }));
|
||||
return answer({ from: url.searchParams.get('from'), to: url.searchParams.get('to'), timezone: 'Asia/Shanghai', scope: { household_name: households.find(h => h.id === usage[1])?.name }, total_duration_ms: count ? 906000 : 0, average_duration_ms: count ? 906000 / count : 0, conversation_count: count, cost_available: false, currency: 'CNY', daily: recent.map(item => ({ date: item.started_at.slice(0, 10), duration_ms: item.duration_ms, conversation_count: 1, cost_micros: 0 })), devices: [], recent_conversations: recent });
|
||||
}
|
||||
if (/\/conversations$|\/stories$|\/memories$|\/facts$|\/events\/history$/.test(path)) return answer({ items: [] });
|
||||
if (path.endsWith('/home-overview')) return answer({ daily: [], conversation_count: 0, total_duration_ms: 0, week_start: '2026-10-05', week_end: '2026-10-12' });
|
||||
return route.fallback();
|
||||
});
|
||||
await page.goto(origin + '/__usage-context' + query);
|
||||
return { page, context, unexpected };
|
||||
}
|
||||
async function nav(page, label, narrow = false) {
|
||||
if (narrow) await page.getByRole('button', { name: '打开导航菜单', exact: true }).click();
|
||||
await page.getByRole('link', { name: label, exact: true }).click();
|
||||
}
|
||||
async function selected(page, name) {
|
||||
await page.locator(`.cp-elder-roster button[aria-pressed="true"]`).filter({ hasText: name }).waitFor();
|
||||
}
|
||||
const deviceControl = page => page.locator('.cp-usage-filters label select');
|
||||
async function usage(page) { await deviceControl(page).waitFor(); }
|
||||
try {
|
||||
await server.listen(); origin = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
browser = await chromium.launch({ headless: true, ...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}) });
|
||||
for (const width of [1188, 389]) {
|
||||
const { page, context, unexpected } = await start({ width, height: 844 });
|
||||
try {
|
||||
await usage(page); await deviceControl(page).selectOption(devices[1].id);
|
||||
await page.locator('.cp-usage-metrics').getByText('15.1', { exact: false }).waitFor();
|
||||
const link = page.getByRole('link', { name: '查看全部聊天记录 →', exact: true });
|
||||
const target = new URL(await link.getAttribute('href'), origin);
|
||||
assert.equal(target.searchParams.get('household_id'), familyA.id);
|
||||
assert.equal(target.searchParams.get('subject_id'), subjects[1].id);
|
||||
assert.equal(target.searchParams.get('usage_device_id'), devices[1].id);
|
||||
assert(!target.searchParams.has('from') && !target.searchParams.has('to'));
|
||||
await link.scrollIntoViewIfNeeded(); await page.screenshot({ path: resolve(output, `usage-${width}.png`) });
|
||||
await link.click(); await page.waitForURL(url => url.searchParams.get('section') === 'conversations');
|
||||
await selected(page, subjects[1].preferred_name);
|
||||
await page.getByRole('navigation', { name: '聊天记录分页', exact: true }).getByText('13', { exact: true }).waitFor();
|
||||
assert.equal(await page.locator('.cp-memory-row').count(), 10, 'first page of 13 records uses the existing pagination');
|
||||
await page.screenshot({ path: resolve(output, `conversations-${width}.png`) });
|
||||
await page.reload(); await selected(page, subjects[1].preferred_name);
|
||||
await page.getByRole('navigation', { name: '聊天记录分页', exact: true }).getByText('13', { exact: true }).waitFor();
|
||||
await page.goBack(); await usage(page); assert.equal(await deviceControl(page).inputValue(), devices[1].id);
|
||||
await page.goForward(); await selected(page, subjects[1].preferred_name);
|
||||
await page.goBack(); await usage(page);
|
||||
// Reverse comparison: a pre-existing manually selected elder must survive.
|
||||
await nav(page, '聊天记录', width < 900); await selected(page, subjects[1].preferred_name);
|
||||
await page.locator('.cp-subject-picker .cp-elder-roster button').filter({ hasText: subjects[0].preferred_name }).click();
|
||||
await page.locator('.cp-subject-picker .cp-elder-roster button').filter({ hasText: subjects[1].preferred_name }).click();
|
||||
await selected(page, subjects[1].preferred_name);
|
||||
await page.locator('summary').filter({ hasText: '查看早期聊天记录' }).click();
|
||||
await page.getByRole('button', { name: '筛选日期与状态', exact: true }).click();
|
||||
await page.locator('.conversation-filter input[type="date"]').nth(0).fill('2001-01-01');
|
||||
await page.locator('.conversation-filter input[type="date"]').nth(1).fill('2001-02-01');
|
||||
await nav(page, '服务用量', width < 900); await usage(page);
|
||||
await deviceControl(page).selectOption(devices[1].id);
|
||||
await page.getByRole('link', { name: '查看全部聊天记录 →', exact: true }).click(); await selected(page, subjects[1].preferred_name);
|
||||
assert.deepEqual(await page.locator('.conversation-filter input[type="date"]').evaluateAll(nodes => nodes.map(node => node.value)), ['', ''], 'all records clears earlier chat date filters');
|
||||
await nav(page, '服务用量', width < 900); await usage(page);
|
||||
await deviceControl(page).selectOption('');
|
||||
assert.match(await page.locator('.cp-usage').innerText(), /全部.*日期|不.*日期/);
|
||||
await page.getByRole('link', { name: '查看全部聊天记录 →', exact: true }).click(); await selected(page, subjects[1].preferred_name);
|
||||
// The household switch must discard the device even before the roster arrives.
|
||||
await nav(page, '服务用量', width < 900); await usage(page);
|
||||
await deviceControl(page).selectOption(devices[1].id);
|
||||
if (width < 900) await page.getByRole('button', { name: '打开导航菜单', exact: true }).click();
|
||||
await page.getByRole('button', { name: '切换家庭', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: familyB.name, exact: true }).click();
|
||||
if (width < 900) await page.locator('.ch-mobile-drawer .ant-drawer-close').click();
|
||||
await usage(page); await page.waitForFunction(device => [...document.querySelectorAll('.cp-usage select option')].some(o => o.value === device), id(520));
|
||||
assert.equal(await deviceControl(page).inputValue(), '');
|
||||
await page.getByRole('link', { name: '查看全部聊天记录 →', exact: true }).click(); await selected(page, roster[2].preferred_name);
|
||||
await page.locator('.cp-memory-row').nth(1).waitFor(); assert.equal(await page.locator('.cp-memory-row').count(), 2);
|
||||
await page.goBack(); await usage(page); assert.equal(await deviceControl(page).inputValue(), '');
|
||||
assert.deepEqual(unexpected, []); checks.push(`${width}px: device owner, existing elder, all devices, household switch, Back`);
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: resolve(output, `failure-${width}.png`) });
|
||||
await writeFile(resolve(output, `failure-${width}.txt`), await page.locator('body').innerText());
|
||||
throw error;
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
for (const params of [
|
||||
`household_id=${familyA.id}&subject_id=${id(410)}&usage_device_id=${id(520)}`,
|
||||
`household_id=${familyB.id}&subject_id=${subjects[1].id}&usage_device_id=${devices[1].id}`,
|
||||
`household_id=${id(999)}&subject_id=${id(999)}&usage_device_id=${id(999)}`,
|
||||
`household_id=${familyA.id}&usage_device_id=${id(530)}`,
|
||||
`household_id=${familyA.id}&subject_id=${subjects[0].id}&usage_device_id=${devices[1].id}`,
|
||||
]) {
|
||||
const { page, context, unexpected } = await start({ width: 1188, height: 761 }, '?section=conversations&' + params);
|
||||
const expected = params.includes(`household_id=${familyB.id}`) ? roster[2] : params.includes(`usage_device_id=${devices[1].id}`) ? subjects[1] : subjects[0];
|
||||
await selected(page, expected.preferred_name); assert.deepEqual(unexpected, []);
|
||||
await context.close(); checks.push(`Validated direct route: ${params}`);
|
||||
}
|
||||
assert(reads.filter(r => r.kind === 'roster').every(r => households.some(h => h.id === r.householdID)));
|
||||
assert(reads.filter(r => r.kind === 'calls').every(r => roster.some(s => s.id === r.elder_id)));
|
||||
assert.deepEqual(mutations, []); assert.deepEqual(external, []); assert.deepEqual(textRequests, []); assert.deepEqual(errors, []);
|
||||
const result = { passed: true, checks, reads, mutations, external, textRequests, browserErrors: errors };
|
||||
await writeFile(resolve(output, 'checks.json'), JSON.stringify(result, null, 2)); console.log(JSON.stringify({ ...result, reads: reads.length }));
|
||||
} catch (error) { await writeFile(resolve(output, 'checks.json'), JSON.stringify({ passed: false, error: error.message, checks, reads, mutations, external, textRequests, browserErrors: errors }, null, 2)); throw error; }
|
||||
finally { await browser?.close(); await server.close(); assert(resolve(work).startsWith(resolve(tmpdir()) + (process.platform === 'win32' ? '\\' : '/'))); await rm(work, { recursive: true, force: true }); }
|
||||
Reference in New Issue
Block a user