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('
'); }); }, 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.clientHeight0,'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}));