Files
HMDesign/packages/sw/tests/home-menu-integration.browser.mjs
T

179 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <evidence-dir> (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--<id> -> sw-header--home-menu-<id>).
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