Files
story/test/e2e/home-drawer-regression.mjs
T

159 lines
12 KiB
JavaScript
Raw Normal View History

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