// Focused Overview integration checks. Build Storybook first; historical evidence stays untouched. // Usage: node packages/sw/tests/overview.browser.mjs [evidence-dir] import { readFile, writeFile, mkdir } from 'node:fs/promises' import { execFileSync } from 'node:child_process' import path from 'node:path' import http from 'node:http' import { parse } from '@babel/parser' import { swStoryEntries, retainedInventory, inventorySignature, retiredDropdownIds, integratedChangedFiles, integratedStoryIds, retiredPropertyPanelIds, retiredUnusedDemoIds, movedHomeMenuIds, retiredHomeMenuCompareIds, movedToPopupIds, approvedMainCommit, homeMenuSidebarIds, withApprovedEntryChanges, approvedHeaderDeclarations, movedHeaderDeclarations } from './storybook-inventory.mjs' import { loadPlaywright } from './playwright-runtime.mjs' import { sourceChanges, unapprovedChanges } from './source-changes.mjs' const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-overview-qa') await mkdir(out, { recursive: true }) // 2026-10-20 (test alignment): 50bf5bd and its before-index.json stay the baseline. The checks below accept only the // approved changes after it and the approved main merge, both recorded in storybook-inventory.mjs. const report = { baseCommit: '50bf5bda8e956a4973b923d64131f2f2285bd38c', approvedMainCommit, checks: [], errors: [], sizes: [] } const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name) } const before = JSON.parse(await readFile(path.join(out, 'before-index.json'), 'utf8')) const index = JSON.parse(await readFile('storybook-static/index.json', 'utf8')) const after = swStoryEntries(index) await writeFile(path.join(out, 'after-index.json'), JSON.stringify(after, null, 2) + '\n') const removedHeaderIds = [...retiredPropertyPanelIds, ...retiredUnusedDemoIds, ...movedToPopupIds] const addedHeaderIds = [...integratedStoryIds.filter(id => id.startsWith('sw-header--')), ...homeMenuSidebarIds, ...Object.values(movedHomeMenuIds).filter(id => !retiredHomeMenuCompareIds.includes(id))] const expectedRetained = retainedInventory(before).filter(entry => !removedHeaderIds.includes(entry.id)).map(withApprovedEntryChanges) const inventory = { expected: expectedRetained.length + addedHeaderIds.length, actual: after.length, missingAdded: addedHeaderIds.filter(id => !after.some(e => e.id === id)), movedToPopup: movedToPopupIds.map(id => index.entries[id]?.title ?? null) } check(`${inventory.expected} SW/Header Story IDs: the 24 retained at 50bf5bd (names, tags) minus approved retirements and the Popup move, plus approved additions`, after.length === inventory.expected && inventory.missingAdded.length === 0 && inventory.movedToPopup.every(title => title === 'SW/Popup') && JSON.stringify(inventorySignature(expectedRetained)) === JSON.stringify(inventorySignature(after.filter(e => !addedHeaderIds.includes(e.id)))), inventory) const source = await readFile('src/sw/sw-header.stories.tsx', 'utf8') const baseline = execFileSync('git', ['show', `${report.baseCommit}:src/sw/sw-header.stories.tsx`], { encoding: 'utf8' }) const stories = text => Object.fromEntries(parse(text, { sourceType:'module', plugins:['typescript','jsx'] }).program.body .filter(n => n.type === 'ExportNamedDeclaration' && n.declaration.type === 'VariableDeclaration').map(n => [n.declaration.declarations[0].id.name, text.slice(n.start,n.end).replaceAll('\r\n','\n')])) const oldStories = stories(baseline), newStories = stories(source) const retiredExports = ['LayerDropdown','ColorDropdown','LineStyleDropdown','TextDimensionStyleDropdown','ComposedControls'] // A declaration changed after 50bf5bd must equal its version at the approving commit; a removed one must be an approved // retirement, or the Popup move compared in its new file. All others stay byte-identical to 50bf5bd. const gitStories = (commit, file) => stories(execFileSync('git', ['show', `${commit}:${file}`], { encoding: 'utf8' })) const storyId = name => `sw-header--${name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()}` const approvedDeclarations = approvedHeaderDeclarations, movedDeclarations = movedHeaderDeclarations const movedNow = Object.fromEntries(await Promise.all(Object.entries(movedDeclarations).map(async ([name, [file]]) => [name, stories(await readFile(file, 'utf8'))[name]]))) const declarationIssues = Object.keys(oldStories).filter(k => k !== 'Overview' && !retiredExports.includes(k)).filter(k => { if (movedDeclarations[k]) return !!newStories[k] || movedNow[k] !== gitStories(movedDeclarations[k][1], movedDeclarations[k][0])[k] if (!newStories[k]) return ![...retiredPropertyPanelIds, ...retiredUnusedDemoIds].includes(storyId(k)) return newStories[k] !== (approvedDeclarations[k] ? gitStories(approvedDeclarations[k], 'src/sw/sw-header.stories.tsx')[k] : oldStories[k]) }) check('retained non-Overview declarations byte-identical to 50bf5bd or their approving commit; only approved removals; relocated composition separately verified', declarationIssues.length === 0, declarationIssues) check('retired Dropdown IDs absent', retiredDropdownIds.every(id => !after.some(e => e.id === id))) // The package source may differ from the approved main merge only in the integrated-improvement files (their own suites // verify them). Approved package changes after 50bf5bd are listed as sinceBaseline; update approvedMainCommit on the next approved merge. // Untracked new source files count as changes (source-changes.mjs). const sourceDiff = { unapproved: unapprovedChanges(report.approvedMainCommit, ['packages/sw/src'], integratedChangedFiles), sinceBaseline: sourceChanges(report.baseCommit, ['packages/sw/src']).length } check('package production source unchanged since the approved main merge except the integrated-improvement files', sourceDiff.unapproved.length === 0, sourceDiff) const root = path.resolve('storybook-static'), mime = { '.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.svg':'image/svg+xml','.ttf':'font/ttf','.png':'image/png' } const server = http.createServer(async(req,res) => { try { const p = new URL(req.url,'http://localhost').pathname, file = path.resolve(root,'.'+decodeURIComponent(p === '/' ? '/index.html' : p)) if (path.relative(root,file).startsWith('..')) return res.writeHead(403).end() res.setHeader('Content-Type',mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } }) await new Promise(resolve => server.listen(0,'127.0.0.1',resolve)) const base = `http://127.0.0.1:${server.address().port}` const { chromium } = loadPlaywright() let browser, page try { browser = await chromium.launch({ headless:true, executablePath:process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' }) report.browser = browser.version() page = await browser.newPage({ viewport:{width:1440,height:1000} }); page.setDefaultTimeout(6000) page.on('pageerror', e => report.errors.push(e.message)); page.on('console', m => { if(m.type() === 'error') report.errors.push(m.text()) }) const go = async(id='overview') => { await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story${id === 'overview' ? '&verification' : ''}`) await page.locator('.hm-sw-header,.hm-sw-theme').first().waitFor(); await page.evaluate(() => document.fonts.ready) await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))) } const geometry = () => page.evaluate(() => { const box = e => { const r=e.getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height,right:r.right,bottom:r.bottom} } const stage=document.querySelector('[data-preview-stage]'),slot=document.querySelector('[data-preview-slot]') return {stage:box(stage),slot:box(slot),header:box(document.querySelector('header.hm-sw-header')),viewport:{w:innerWidth,h:innerHeight},overflow:document.documentElement.scrollWidth-document.documentElement.clientWidth} }) if(process.argv[3]==='overlays'){ await go() const stage=page.locator('[data-preview-stage]'),header=page.locator('header.hm-sw-header') const covered=locator=>locator.evaluate(e=>{const r=e.getBoundingClientRect();return e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2))}) await header.locator('.hm-sw-header__document-button').click() const firstDocument=header.locator('.hm-sw-header__document-item').first() check('document first row is above the property Bar and clickable',await covered(firstDocument));await firstDocument.click() for(const [id,label,detail]of[['layer','레이어','레이어 상세'],['color','색상','색상 상세'],['line-style','선스타일','선스타일 상세'],['text-style','문자스타일','문자스타일 상세'],['dimension-style','치수스타일','치수스타일 상세']]){ await stage.evaluate(e=>e.scrollTop=0) const button=header.getByRole('button',{name:label,exact:true}) if(await button.isVisible())await button.click();else{await header.getByRole('button',{name:'더보기',exact:true}).click();await header.locator(`[role=menuitem][data-menu="${id}"]`).click()} await header.getByRole('button',{name:detail,exact:true}).click() check(`existing ${detail} reports Callback`,(await page.locator('[data-preview-callback]').textContent()).includes(`on${{'layer':'Layer','color':'Color','line-style':'LineStyle','text-style':'TextStyle','dimension-style':'DimensionStyle'}[id]}Detail`)) } await header.getByRole('button',{name:'색상',exact:true}).click() await header.getByRole('button',{name:'ByBlock',exact:true}).click() check('ByBlock mode immediately appears in Header without changing numeric RGB property',(await header.locator('.hm-sw-header__field--color').textContent()).includes('ByBlock')) await page.keyboard.press('Escape') await header.getByRole('button',{name:'색상',exact:true}).click() await header.locator('.hm-sw-header__color-option').first().click() check('direct color selection clears Header mode label',!(await header.locator('.hm-sw-header__field--color').textContent()).includes('ByBlock')) await page.setViewportSize({width:390,height:700});await go();await page.getByLabel('선택 상태',{exact:true}).selectOption('expanded') await header.getByRole('button',{name:'더보기',exact:true}).click();await header.getByRole('menuitemcheckbox',{name:'속성창',exact:true}).click() for(const id of ['layer','line','color']){ const field=page.locator(`.hm-sw-property [data-property-id="${id}"]`),trigger=field.locator(id==='color'?'input':'button') await trigger.scrollIntoViewIfNeeded();await trigger.click() const drop=page.locator('.hm-sw-property__drop');await drop.waitFor() const r=await drop.boundingBox() check(`small Inner ${id} dropdown stays in browser width`,r.x>=0&&r.x+r.width<=390.5,r) if(id==='layer'){const last=drop.getByRole('button',{name:'Sample-Text',exact:true});check('small Inner lower layer row hit-test is unobstructed',await covered(last));await last.click()} else await page.keyboard.press('Escape') check(`small Inner ${id} selection or Escape closes only nested dropdown`,await drop.count()===0&&await page.locator('.hm-sw-property').count()===1) } await page.screenshot({path:path.join(out,'overview-narrow-overlay.png')}) check('no overlay browser errors',report.errors.length===0,report.errors) }else{ for(const [width,height] of [[1920,1080],[1440,900],[1280,720],[768,600],[390,844],[1000,500]]) { await page.setViewportSize({width,height}); await go() const g=await geometry(); report.sizes.push(g) check(`16:9 fitted to both Inner dimensions at ${width}x${height}`,g.stage.w>0&&Math.abs(g.stage.w/g.stage.h-16/9)<.005&&g.stage.w<=g.slot.w+.1&&g.stage.h<=g.slot.h+.1&&g.header.h===49&&g.overflow===0,g) } await page.setViewportSize({width:1440,height:1000}); await go() const header=page.locator('header.hm-sw-header'),stage=page.locator('[data-preview-stage]') const callback=()=>page.locator('[data-preview-callback]').textContent() const open=async(id,label) => { await stage.evaluate(e=>{e.scrollTop=0;e.scrollLeft=0}) const button=header.getByRole('button',{name:label,exact:true}) if(await button.isVisible()) {if(await button.getAttribute('aria-expanded')!=='true')await button.click()} else {await header.getByRole('button',{name:'더보기',exact:true}).click();await header.locator(`[role="menuitem"][data-menu="${id}"]`).click()} } const panelButton=async()=>{ const b=header.locator('[data-property-panel-trigger]') if(await b.isVisible())await b.click() else{await header.getByRole('button',{name:'더보기',exact:true}).click();await header.getByRole('menuitemcheckbox',{name:'속성창',exact:true}).click()} } // 2026-10-21 (user decision): the Bar values (반지름 / 지름 / 원주 / 면적) are read-only red text (, edited in the panel), never focused. const barValue=id=>page.locator(`.hm-sw-property-bar [data-property-id="${id}"] output`) const waitText=(id,value)=>page.waitForFunction(([id,value])=>document.querySelector(`.hm-sw-property-bar [data-property-id="${id}"] output`)?.textContent===value,[id,value]) const panelInput=id=>page.locator(`.hm-sw-property [data-property-id="${id}"] input`).first() const waitValue=async(locator,value)=>page.waitForFunction(([element,value])=>element.value===value,[await locator.elementHandle(),value]) check('one Header and no duplicate fixed Figma specimen',await header.count()===1&&await page.getByText('Figma 기준 1920 × 49',{exact:true}).count()===0) await header.locator('.hm-sw-header__document-button').click() const document=header.locator('.hm-sw-header__document-list .hm-sw-header__document-item').nth(1) const documentName=await document.textContent();await document.click() check('document selection updates Header and Callback',!!documentName&&(await header.locator('.hm-sw-header__document-button').textContent()).includes(documentName.trim())&&(await callback()).includes('onDocumentSelect'),await callback()) await open('layer','레이어');await header.getByRole('button',{name:'CC-D GNL',exact:true}).click() check('layer selection updates shared data', (await callback()).includes('onLayerSelect(layer, CC-D GNL)'),await callback()) await open('layer','레이어');await header.getByRole('button',{name:'레이어 상세',exact:true}).click() check('existing layer Detail callback closes list',await header.locator('.hm-sw-header__layer-panel').count()===0&&(await callback()).includes('onLayerDetail')) await open('color','색상') const chip=header.locator('.hm-sw-header__color-menu .hm-sw-header__color-option').first() await chip.click();check('color selection invokes host callback',(await callback()).includes('onColorSelect'),await callback()) await open('line-style','선스타일');await header.getByRole('button',{name:'CENTER',exact:true}).click() check('line selection updates Header', (await callback()).includes('onLineTypeSelect(line, CENTER)'),await callback()) await open('line-style','선스타일');await header.locator('ul[aria-label="가중치"]').getByRole('button',{name:'ByLayer',exact:true}).click() check('ByLayer weight mode does not turn numeric property into a mode string',(await header.locator('.hm-sw-header__field--line-style').textContent()).includes('ByLayer')) await page.keyboard.press('Escape') await open('text-style','문자스타일');await header.locator('.hm-sw-header__list-menu--text-style').getByRole('button',{name:/Dotum/}).click() check('text style selection updates Header',(await callback()).includes('onTextStyleSelect(dotum)'),await callback()) await open('dimension-style','치수스타일');await header.locator('.hm-sw-header__list-menu--dimension-style').getByRole('button',{name:/ISO - 25/}).click() check('dimension style selection updates Header',(await callback()).includes('onDimensionStyleSelect(iso-25)'),await callback()) await open('color','색상');await open('layer','레이어') check('opening another dropdown closes the previous one',await header.locator('.hm-sw-header__color-menu').count()===0&&await header.locator('.hm-sw-header__layer-panel').count()===1) await page.keyboard.press('Escape');check('Escape closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0) await open('layer','레이어');await page.locator('.sb-page-title').click();check('outside click closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0) const barState=await page.locator('.hm-sw-property-bar').evaluate(bar=>({object:bar.querySelector('.hm-sw-property-bar__object').textContent,order:[...bar.querySelectorAll('[data-property-id]')].map(e=>e.dataset.propertyId),inputs:bar.querySelectorAll('input').length,focusable:[...bar.querySelectorAll('output')].filter(e=>e.tabIndex>=0).length,colors:[...new Set([...bar.querySelectorAll('output')].map(e=>getComputedStyle(e).color))]})) check('Bar: object text 원, order 반지름 / 지름 / 원주 / 면적, read-only red text (edited in the panel) without input or focus stop',barState.object==='원'&&barState.order.join()==='radius,diameter,circumference,area'&&barState.inputs===0&&barState.focusable===0&&barState.colors.join()==='rgb(230, 4, 3)',barState) await barValue('radius').click();check('Bar value click never focuses the value',await page.evaluate(()=>document.activeElement?.tagName!=='OUTPUT'&&document.activeElement?.tagName!=='INPUT')) await panelButton();await page.locator('.hm-sw-property').waitFor() check('Header opens panel with same Bar value',await panelInput('radius').inputValue()===await barValue('radius').textContent()) check('numeric weight property remains numeric after ByLayer mode',await panelInput('weight').inputValue()==='0.25') check('Header layer value shared with panel',(await page.locator('.hm-sw-property [data-property-id="layer"]').textContent()).includes('CC-D GNL')) await panelInput('radius').fill('7.5');await panelInput('radius').press('Enter') await waitText('radius','7.5') check('panel edit updates the Bar',await barValue('radius').textContent()==='7.5'&&(await callback()).includes('onPropertyChange(radius, 7.5)')) await panelInput('radius').fill('-2');await panelInput('radius').press('Enter');check('panel number validation rejects negative radius',await panelInput('radius').inputValue()==='7.5') await page.locator('.hm-sw-property [data-property-id="layer"] button').click() const drop=page.locator('.hm-sw-property__drop') await drop.getByRole('button',{name:'Sample-Wall',exact:true}).click() check('panel Layer dropdown updates Header shared value',(await header.locator('.hm-sw-header__field--layer').textContent()).includes('Sample-Wall')) await page.locator('.hm-sw-property [data-property-id="line"] button').click();await drop.getByRole('button',{name:'HIDDEN',exact:true}).click() check('panel line dropdown updates Header shared value',(await header.locator('.hm-sw-header__field--line-style').textContent()).includes('HIDDEN')) await panelInput('color').click() const colorOption=drop.locator('.hm-sw-header__color-option').nth(1) const expectedColor=await colorOption.evaluate(e=>getComputedStyle(e.querySelector('.hm-sw-header__color-chip > span')).backgroundColor.match(/\d+/g).slice(0,3).join(',')) await colorOption.click();await waitValue(panelInput('color'),expectedColor) const selectedColor=await panelInput('color').inputValue() check('panel color dropdown updates Header shared value',(await header.locator('.hm-sw-header__field--color').textContent()).includes(selectedColor)) await panelInput('color').fill('300,0,0');await panelInput('color').press('Enter');check('panel RGB validation rejects out-of-range value',await panelInput('color').inputValue()===selectedColor) await page.keyboard.press('Escape') const gauge=page.locator('.hm-sw-property input[type="range"]') await gauge.press('End');await gauge.press('Enter');await waitValue(panelInput('opacity'),'1') check('gauge keyboard editing commits host state',await panelInput('opacity').inputValue()==='1') await panelInput('radius').fill('8.5');await panelInput('radius').press('Escape') check('first Escape cancels draft and retains panel',await panelInput('radius').inputValue()==='7.5'&&await page.locator('.hm-sw-property').count()===1) await panelInput('radius').press('Escape');check('next Escape closes panel and restores Header focus',await page.locator('.hm-sw-property').count()===0&&await header.locator('[data-property-panel-trigger]').evaluate(e=>document.activeElement===e)) await panelButton();await page.getByRole('button',{name:'속성창 닫기',exact:true}).click();check('panel Close button restores focus',await header.locator('[data-property-panel-trigger]').evaluate(e=>document.activeElement===e)) await page.getByText('시연 데이터',{exact:true}).click() await page.getByRole('button',{name:'외부 값 12.5',exact:true}).click();await waitText('radius','12.5');check('external Props update Bar',await barValue('radius').textContent()==='12.5') await page.getByRole('checkbox',{name:'속성 ReadOnly',exact:true}).check();check('Bar ReadOnly: text, no input',await barValue('radius').count()===1&&await page.locator('.hm-sw-property-bar input').count()===0) await page.getByRole('checkbox',{name:'속성 ReadOnly',exact:true}).uncheck() await page.getByRole('checkbox',{name:'속성 Disabled',exact:true}).check();check('Bar Disabled: dimmed text',await barValue('radius').getAttribute('data-disabled')==='true') await page.getByRole('checkbox',{name:'속성 Disabled',exact:true}).uncheck() await page.getByRole('button',{name:'필드 추가',exact:true}).click();check('dynamic Bar field added',await page.locator('.hm-sw-property-bar [data-property-id^="preview-extra-"]').count()===1) await panelButton();check('dynamic field shared with panel',await page.locator('.hm-sw-property [data-property-id^="preview-extra-"]').count()===1) await page.getByRole('button',{name:'추가 필드 제거',exact:true}).click();check('dynamic field removed from both',await page.locator('[data-property-id^="preview-extra-"]').count()===0) await page.getByRole('checkbox',{name:'형상 Slot 비우기',exact:true}).check();check('shape Slot can be empty without changing panel structure',await page.locator('.hm-sw-property [data-shape-slot="empty"]').count()===1) await page.getByRole('checkbox',{name:'형상 Slot 비우기',exact:true}).uncheck() await page.getByText('시연 데이터',{exact:true}).click() for(const [selection,height]of[['none',256],['all',361],['circle',658],['expanded',754]]){ await page.getByLabel('선택 상태',{exact:true}).selectOption(selection) if(await page.locator('.hm-sw-property').count()===0)await panelButton() const box=await page.locator('.hm-sw-property').boundingBox() check(`original panel size ${selection}:314x${height}`,box.width===314&&box.height===height,box) } await page.screenshot({path:path.join(out,'overview-after-expanded.png')}) await stage.evaluate(e=>{e.scrollTop=e.scrollHeight}) await panelInput('z').scrollIntoViewIfNeeded();await panelInput('z').fill('5');await panelInput('z').press('Enter') check('expanded popup bottom accessible through internal scroll',await panelInput('z').inputValue()==='5') await page.screenshot({path:path.join(out,'overview-after-scrolled.png')}) await go();await page.screenshot({path:path.join(out,'overview-after.png')}) // A small Inner keeps native component dimensions; the Stage, rather than the browser document, scrolls. await page.setViewportSize({width:390,height:700});await go();await page.getByLabel('선택 상태',{exact:true}).selectOption('expanded');await panelButton() await panelInput('z').scrollIntoViewIfNeeded();await panelInput('z').fill('3');await panelInput('z').press('Enter') const narrow=await stage.evaluate(e=>({top:e.scrollTop,left:e.scrollLeft,height:e.clientHeight,scrollHeight:e.scrollHeight,width:e.clientWidth,scrollWidth:e.scrollWidth})) check('small Inner reaches full popup bottom with native Header and popup dimensions',await panelInput('z').inputValue()==='3'&&narrow.scrollHeight>narrow.height&&narrow.scrollWidth>=390,narrow) await page.screenshot({path:path.join(out,'overview-narrow.png')}) await panelInput('z').press('Escape') check('narrow popup Escape restores More focus',await header.getByRole('button',{name:'더보기',exact:true}).evaluate(e=>document.activeElement===e)) await page.setViewportSize({width:1440,height:1000});await page.goto(`${base}/?path=/story/sw-header--overview`) const preview=page.frameLocator('#storybook-preview-iframe');await preview.locator('[data-preview-stage]').waitFor() report.managerOrder=await page.locator('[data-item-id^="sw-header--"]').evaluateAll(elements=>[...new Set(elements.map(e=>e.dataset.itemId))]) await writeFile(path.join(out,'manager-story-order.json'),JSON.stringify(report.managerOrder,null,2)+'\n') await page.screenshot({path:path.join(out,'overview-manager.png')}) const managerFit=await preview.locator('[data-preview-stage]').evaluate(e=>{const b=e.getBoundingClientRect(),slot=e.parentElement.getBoundingClientRect();return {width:b.width,height:b.height,slotWidth:slot.width,slotHeight:slot.height,viewportWidth:innerWidth,viewportHeight:innerHeight}}) check('actual Storybook manager iframe fits its own Inner',managerFit.width<=managerFit.slotWidth+.1&&managerFit.height<=managerFit.slotHeight+.1&&Math.abs(managerFit.width/managerFit.height-16/9)<.005,managerFit) check('no browser errors',report.errors.length===0,report.errors) } }catch(error){check('suite completes',false,error.stack);await page?.screenshot({path:path.join(out,'failure.png')}).catch(()=>{})} finally{ await browser?.close();await new Promise(resolve=>server.close(resolve)) report.status=report.checks.every(c=>c.status==='PASS')?'PASS':'FAIL' await writeFile(path.join(out,process.argv[3]==='overlays'?'chrome-overlays-result.json':'chrome-result.json'),JSON.stringify(report,null,2)+'\n') console.log(JSON.stringify({status:report.status,checks:report.checks.length,failures:report.checks.filter(c=>c.status==='FAIL')})) process.exitCode=report.status==='PASS'?0:1 }