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.clientHeight