322 lines
28 KiB
JavaScript
322 lines
28 KiB
JavaScript
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();}
|