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,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();}
|
||||
Reference in New Issue
Block a user