179 lines
14 KiB
JavaScript
179 lines
14 KiB
JavaScript
// 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
|