Files
HMDesign/packages/sw/tests/home-menu-integration.browser.mjs
T
shkim4 3a5e29f2fa Merge branch 'integration/sw-home-menu' into feature/sw-home-menu
Integrate the SW home (logo) menu into SWHeader for SW general mode only.
SW viewer mode code and stories are unchanged.

- Resolve conflicts: keep both sw-home-menu and sw-viewer exports;
  renumber AI docs (catalog viewer 8->9, patterns home menu 7->8,
  design rules home menu 11->12)
- SWHeader: start the home menu after the file name when it sits
  inside controls, so the file name stays visible
- AI guides: mark home menu as general-mode only; forbid it in viewer
  mode (AI_USAGE, catalog, design rules, patterns, VIEWER.md, AI_GUIDE)
- Tests: ignore wildcard identifiers in ai-docs check; update expected
  Storybook SW titles (SW/Tool removed, SW/Viewer added)
- Add merge QA report docs/sw/sw-home-menu-merge-2026-10-19.md
2026-10-19 18:21:19 +09:00

172 lines
13 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 { createRequire } from 'node:module'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
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 => {
await page.goto(`${base}/iframe.html?id=sw-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, Home Menu, Viewer (SW/Tool removed 2026-10-19); no SW/Header/Options', ['SW/Header', 'SW/Popup', 'SW/Home Menu', 'SW/Viewer'].every(t => titles.includes(t)) && !titles.includes('SW/Tool') && !titles.some(t => t.startsWith('SW/Header/')), 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()
const tiles = [...document.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 = [...document.querySelectorAll('.hm-sw-home-menu__icon img')]
return {
left: m.left - header.left, width: m.width, height: m.height, headerHeight: header.height,
sections: [...document.querySelectorAll('.hm-sw-home-menu__section')].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: document.querySelectorAll('.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()
const pressedOn = await tile.getAttribute('aria-pressed')
await tile.click()
check('tile click → Active (aria-pressed true), re-click → Default', pressedOn === 'true' && await tile.getAttribute('aria-pressed') === 'false', pressedOn)
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), internal scroll reported
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 }
})
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)
// 8. Without homeMenu (existing Header): the logo still calls onHomeClick only, no menu, no aria-expanded
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--overview&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('onHomeClick')) && 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