// Core checks of the Task A + B integration (SWHeader homeMenu, 2026-10-19) on the built Storybook. // Usage: node packages/sw/tests/home-menu-integration.browser.mjs (build Storybook first) import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import http from 'node:http' import { runtimeRequire } from './playwright-runtime.mjs' const require = runtimeRequire() const out = path.resolve(process.argv[2] ?? 'docs/sw/home-menu-integration-qa/home-menu') await mkdir(out, { recursive: true }) const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' } 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 } = require('playwright') const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' }) const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } }) const report = { browser: browser.version(), checks: [], errors: [], data: {} } page.on('pageerror', e => report.errors.push(e.message)) page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) }) const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, ok || data === undefined ? '' : JSON.stringify(data)) } const goto = async id => { // 2026-10-19: the Home Menu Stories moved under SW/Header (sw-home-menu-- -> sw-header--home-menu-). await page.goto(`${base}/iframe.html?id=sw-header--home-menu-${id}&viewMode=story`) await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor() await page.evaluate(() => document.fonts.ready) await page.mouse.move(1, 1) await page.waitForTimeout(100) } const menu = () => page.locator('.hm-sw-header .hm-sw-home-menu') const home = () => page.locator('.hm-sw-header__home').first() const log = () => page.locator('.sw-home-menu-story__log').innerText() const activeIsHome = () => page.evaluate(() => document.activeElement?.classList.contains('hm-sw-header__home')) // 1. Storybook menu structure const index = JSON.parse(await readFile(path.join(root, 'index.json'), 'utf8')) const titles = [...new Set(Object.values(index.entries).map(entry => entry.title).filter(title => title.startsWith('SW/')))].sort() report.data.titles = titles check('Storybook SW titles: Header, Popup, Viewer (SW/Tool removed, SW/Home Menu merged into SW/Header 2026-10-19); no SW/Header/Options; sw-header--home-menu listed', ['SW/Header', 'SW/Popup', 'SW/Viewer'].every(t => titles.includes(t)) && !titles.includes('SW/Tool') && !titles.includes('SW/Home Menu') && !titles.some(t => t.startsWith('SW/Header/')) && !!index.entries['sw-header--home-menu'], titles) // 2. Figma 26:10591 geometry at 1920 through SWHeader's measured placement await goto('capture-default') const geometry = await page.evaluate(() => { const header = document.querySelector('.hm-sw-header').getBoundingClientRect() const m = document.querySelector('.hm-sw-home-menu').getBoundingClientRect() const r = e => e.getBoundingClientRect() // 2026-10-19: only the visible row; the hidden measure row and the More section also render sections / tiles. const row = [...document.querySelectorAll('.hm-sw-home-menu__sections > .hm-sw-home-menu__section:not(.hm-sw-home-menu__more)')] const tiles = row.flatMap(s => [...s.querySelectorAll('.hm-sw-home-menu__item')]).map(r) const badge = getComputedStyle(document.querySelector('.hm-sw-home-menu')).getPropertyValue('--hm-sw-home-menu-title-art') const icons = row.flatMap(s => [...s.querySelectorAll('.hm-sw-home-menu__icon img')]) return { left: m.left - header.left, width: m.width, height: m.height, headerHeight: header.height, sections: row.map(e => Math.round(r(e).width)), tiles: tiles.length, tileSizes: [...new Set(tiles.map(t => `${Math.round(t.width)}x${Math.round(t.height)}`))], titles: row.filter(s => s.querySelector('.hm-sw-home-menu__title')).length, badgeInlined: badge.includes('data:image/svg+xml') || badge.includes('.svg'), icons: icons.length, iconsLoaded: icons.filter(i => i.complete && i.naturalWidth > 0).length, } }) report.data.geometry = geometry check('menu at Figma x 375, width 1305, height 77 (measured, no fixed x); header stays 49', Math.abs(geometry.left - 375) < 0.6 && Math.abs(geometry.width - 1305) < 0.6 && Math.abs(geometry.height - 77) < 0.6 && geometry.headerHeight === 49, geometry) check('5 sections 340/260/180/260/261, 15 tiles 80x46, 5 titles', JSON.stringify(geometry.sections) === '[340,260,180,260,261]' && geometry.tiles === 15 && geometry.tileSizes.join() === '80x46' && geometry.titles === 5, geometry) check('title badge SVG and 30 tile icons render', geometry.badgeInlined && geometry.icons === 30 && geometry.iconsLoaded === 30, geometry) await page.screenshot({ path: path.join(out, 'capture-default-1920.png'), clip: { x: 0, y: 0, width: 1920, height: 80 } }) // 3. Hidden by default, logo opens / re-click closes, onHomeClick not called, Hover / Active await goto('header-composition') check('hidden by default (not in DOM), logo aria-expanded=false', await menu().count() === 0 && await home().getAttribute('aria-expanded') === 'false') await home().click() check('logo click opens, aria-expanded=true, aria-controls → menu id', await menu().count() === 1 && await home().getAttribute('aria-expanded') === 'true' && await home().getAttribute('aria-controls') === await menu().getAttribute('id')) const tile = page.locator('.hm-sw-home-menu__item[data-item-id="save"]') await tile.hover() await page.waitForTimeout(50) const hover = await tile.evaluate(e => getComputedStyle(e.querySelector('.hm-sw-home-menu__label')).color) check('tile Hover: label #FF5C00', hover === 'rgb(255, 92, 0)', hover) await tile.click() await page.mouse.move(1, 1) // 2026-10-19: tiles without a panel are action buttons — the click runs (onItemSelect) and nothing stays on. const afterClick = await tile.evaluate(e => ({ pressed: e.getAttribute('aria-pressed'), active: e.dataset.active ?? null, ring: getComputedStyle(e, '::after').opacity })) check('action tile click: onItemSelect, no Active / aria-pressed / ring after the click', afterClick.pressed === null && afterClick.active === null && afterClick.ring === '0' && (await log()).includes('onItemSelect(working / save, active=false)'), afterClick) await home().click() const text = await log() check('logo re-click closes; onHomeClick never called while homeMenu is set', await menu().count() === 0 && !text.includes('onHomeClick') && text.includes('onHomeMenuOpenChange(false, home)'), text) // 4. Escape + focus, background click, sub panel first await home().click() await page.keyboard.press('Escape') check('Escape inside closes the menu and returns focus to the logo', await menu().count() === 0 && await activeIsHome()) await home().click() await page.locator('.hm-sw-home-menu__title').first().click() check('click on the menu background closes it (Figma CLOSE)', await menu().count() === 0) await home().click() await page.locator('.hm-sw-home-menu__item[data-item-id="recent"]').click() const panelOpen = await page.locator('.hm-sw-home-menu__panel').count() === 1 && await menu().getAttribute('data-accent') === 'outline' await page.keyboard.press('Escape') const panelFirst = await page.locator('.hm-sw-home-menu__panel').count() === 0 && await menu().count() === 1 await page.keyboard.press('Escape') check('recent panel opens (outline accent); Escape closes the panel, then the menu', panelOpen && panelFirst && await menu().count() === 0, { panelOpen, panelFirst }) // 5. Document dropdown keeps New / Open and never stays open with the menu await home().click() await page.locator('.hm-sw-header__document-button').first().click() const docMenu = page.locator('.hm-sw-header__document-menu') const footer = await docMenu.innerText().catch(() => '') check('opening the document dropdown closes the menu (reason document); New / Open stay in the dropdown', await menu().count() === 0 && footer.includes('New') && footer.includes('Open') && (await log()).includes('onHomeMenuOpenChange(false, document)'), footer) await home().click() check('opening the menu closes the document dropdown', await menu().count() === 1 && await docMenu.count() === 0) check('no separate Open button next to the logo (documentOpenPlacement="menu")', await page.locator('[data-document-open]').count() === 0) await page.screenshot({ path: path.join(out, 'header-composition-open.png'), clip: { x: 0, y: 0, width: 1920, height: 400 } }) // 6. Menu + SWPopup + property window: Escape order and focus outside await goto('integration') const popup = page.locator('.hm-sw-property:has-text("사용자 Popup")') const blur = () => page.locator('.story-page-header, h1').first().click({ position: { x: 2, y: 2 } }).catch(() => page.mouse.click(1900, 990)) await page.getByTestId('open-popup').click() await home().click() await blur() await page.keyboard.press('Escape') const step1 = { menu: await menu().count(), popup: await popup.count() } await page.keyboard.press('Escape') const step2 = { menu: await menu().count(), popup: await popup.count() } check('focus outside: Escape closes the most recent (menu), then the popup', step1.menu === 0 && step1.popup === 1 && step2.popup === 0, { step1, step2 }) await home().click() await page.getByTestId('open-popup').click() await blur() await page.keyboard.press('Escape') const step3 = { menu: await menu().count(), popup: await popup.count() } check('popup opened after the menu: Escape outside closes the popup first, menu stays', step3.popup === 0 && step3.menu === 1, step3) await page.getByTestId('open-popup').click() await popup.locator('button').first().focus() await page.keyboard.press('Escape') const step4 = { menu: await menu().count(), popup: await popup.count() } check('Escape inside the popup closes only the popup', step4.popup === 0 && step4.menu === 1, step4) await page.locator('.hm-sw-home-menu__item').first().focus() await page.getByTestId('open-popup').click() await page.locator('.hm-sw-home-menu__item').first().focus() await page.keyboard.press('Escape') const step5 = { menu: await menu().count(), popup: await popup.count(), focusHome: await activeIsHome() } check('Escape inside the menu closes only the menu (popup stays), focus → logo', step5.menu === 0 && step5.popup === 1 && step5.focusHome, step5) // 7. Responsive placement (measured); narrow widths use the measured More instead of a scroller (2026-10-19) const responsive = [] for (const width of [1920, 1440, 1080, 768, 520, 390]) { await page.setViewportSize({ width, height: 900 }) await goto('capture-responsive') const box = await page.evaluate(() => { const r = s => document.querySelector(s)?.getBoundingClientRect() const header = r('.hm-sw-header'), area = r('.hm-sw-header__object-area'), win = r('.hm-sw-header__window'), m = r('.hm-sw-home-menu'), homeBox = r('.hm-sw-header__home'), doc = r('.hm-sw-header__document') const scroller = document.querySelector('.hm-sw-home-menu__sections') return { headerHeight: header.height, start: m.left - (area.left - 2), endGap: (win?.left ?? header.right) - m.right, coversHome: m.left < homeBox.right - 17, coversDocument: m.left < doc.right - 1, menuWidth: Math.round(m.width), scrolls: scroller.scrollWidth > scroller.clientWidth + 1, pageScroll: document.documentElement.scrollWidth > innerWidth, rowSections: scroller.querySelectorAll(':scope > .hm-sw-home-menu__section:not(.hm-sw-home-menu__more)').length, moved: document.querySelectorAll('.hm-sw-home-menu__more-panel > .hm-sw-home-menu__text-group').length } }) responsive.push({ width, ...box }) if (width === 390 || width === 768) await page.screenshot({ path: path.join(out, `responsive-${width}.png`), clip: { x: 0, y: 0, width, height: 90 } }) } report.data.responsive = responsive check('every width: menu starts at object area −2px, ends at the window area, covers neither logo nor file name, header 49, no page scroll', responsive.every(r => Math.abs(r.start) < 0.6 && r.endGap > -0.6 && !r.coversHome && !r.coversDocument && r.headerHeight === 49 && !r.pageScroll), responsive) check('every width: no horizontal scroll in the menu; 5 sections = row + More (1920: all in the row)', responsive.every(r => !r.scrolls && r.rowSections + r.moved === 5 && (r.width !== 1920 || r.moved === 0)), responsive) // 8. Without homeMenu (existing Header): the logo still calls onHomeClick only, no menu, no aria-expanded // 2026-10-20: Overview passes homeMenu since 170a5c2 (docs/sw/sw-header-usage.md "Overview의 로고도 같은 홈 메뉴"), // so this uses the first Logo Slot header (figmaArgs + callbacks, no homeMenu; onHomeClick logs "[SWHeader] home"). await page.setViewportSize({ width: 1920, height: 1000 }) const infos = [] page.on('console', m => { if (m.type() === 'info') infos.push(m.text()) }) await page.goto(`${base}/iframe.html?id=sw-header--logo-slot&viewMode=story`) await page.locator('.hm-sw-header').first().waitFor() await home().click() await page.waitForTimeout(50) check('without homeMenu: logo click calls onHomeClick, renders no menu, no aria-expanded', infos.some(text => text.includes('[SWHeader] home')) && await page.locator('.hm-sw-home-menu').count() === 0 && await home().getAttribute('aria-expanded') === null, infos) check('no page / console errors', report.errors.length === 0, report.errors) await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2)) console.log(`${report.checks.filter(c => c.status === 'PASS').length}/${report.checks.length} PASS`) await browser.close(); server.close() process.exitCode = report.checks.every(c => c.status === 'PASS') ? 0 : 1