159 lines
12 KiB
JavaScript
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}));
|