Files
story/test/e2e/home-drawer-regression.mjs
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
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.
2026-10-08 10:06:44 +08:00

159 lines
12 KiB
JavaScript

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}));