Files
HMDesign/packages/sw/tests/theme-inheritance.browser.mjs
T
shkim4 6c61edadc1 fix(storybook): refine responsive page inner width
- Story page Inner side margin: 16px up to a 480px viewport, linear to 60px at 768px, 60px beyond (no width jump)
- Follow the 60px margins: Responsive 1920 row from a 2040px page (was 2000px); property compare 3 columns from 1125px, 2 columns from 787px (was 1080 / 720)
- SW browser tests: drop the removed --sb-page-max-width override, compare thresholds 787 / 1125
2026-11-05 15:59:41 +09:00

288 lines
21 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.
// SW theme token inheritance (2026-10-20, A-1).
// 1) SW/Theme › Theme Inheritance: a token set through `style` on the outermost SW scope reaches the header, dropdowns,
// More, panels, footer command line and tooltips; portals get it only when wrapped in a themed <SWTheme>; areas do
// not mix; unthemed components keep Noto Sans KR. Dotum is a verification value only (not the SW default).
// 2) Regression: every existing SW Story renders identically with the new cascade and with the legacy cascade
// (token blocks re-declared on every `.hm-sw-header, .hm-sw-theme`, i.e. the code before this change): computed
// font / color / geometry of every SW element and an element screenshot comparison. No Story sets an override,
// so any difference would be a regression. Pixels are compared with a control shot taken after the CURRENT token
// blocks are injected once more (2026-10-20): re-declaring them alone re-rasterizes a few gradient pixels.
// Usage: node packages/sw/tests/theme-inheritance.browser.mjs <storybook-url> <out-dir>
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { runtimeRequire } from './playwright-runtime.mjs'
const require = runtimeRequire()
const { chromium } = require('playwright')
const { PNG } = require('pngjs')
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
const [storybook = 'http://127.0.0.1:6024', outArg] = process.argv.slice(2)
const out = path.resolve(outArg ?? 'docs/sw/theme-inheritance-qa')
await mkdir(out, { recursive: true })
const VERIFY = 'Dotum, 돋움, sans-serif'
const NESTED = '"Malgun Gothic", sans-serif'
const DEFAULT = '"Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif'
const results = []
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) }
const errors = []
// 2026-10-20: software raster without font hinting / subpixel positioning, as integrated-visual / viewer-regression: the
// regression pass failed on varying Stories (Home Menu, Footer, Responsive More) on the same build, before and after the change.
const browser = await chromium.launch({ headless: true, args: ['--disable-gpu', '--font-render-hinting=none', '--disable-font-subpixel-positioning', '--disable-lcd-text', '--force-color-profile=srgb'], executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const newPage = async (width = 1440, height = 1000) => {
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' })
page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`))
page.on('console', message => { if (message.type() === 'error' && !/favicon|404/.test(message.text())) errors.push(`${page.url()}: ${message.text()}`) })
return page
}
const open = async (page, id) => {
await page.goto(`${storybook}/iframe.html?id=${id}&viewMode=story`)
await page.waitForSelector('#storybook-root > *')
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(300)
}
// Font of every element with its own text under `selector` (header items, menu rows, panel controls …).
const fonts = (page, selector, exclude = null) => page.evaluate(([selector, exclude]) => {
const map = {}
for (const root of document.querySelectorAll(selector)) for (const element of [root, ...root.querySelectorAll('*')]) {
// Story content (fixture classes) and text-less range inputs are not SW typography.
if (element.closest('svg') || element.matches('[class^="sw-"], input[type="range"]') || (exclude && element.matches(exclude))) continue
const own = [...element.childNodes].some(node => node.nodeType === 3 && node.textContent.trim()) || /^(INPUT|TEXTAREA|BUTTON)$/.test(element.tagName)
if (!own) continue
const family = getComputedStyle(element).fontFamily
map[family] = (map[family] ?? 0) + 1
}
return map
}, [selector, exclude])
const only = (map, expected) => Object.keys(map).length > 0 && Object.keys(map).every(family => family === expected)
const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${family}`).join(' | ') || 'no text'
// More (2026-10-21): the header shows More only when its measured room runs out (full → icons → More). The Story's
// 1000px header fits as icons, so the header is narrowed for this check (as text-weight does), then restored.
async function openMore(page, area) {
const header = page.locator(`${area} .hm-sw-viewer-header`)
await header.evaluate(element => { element.style.width = "700px" })
await page.locator(`${area} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
}
const restoreWidth = (page, area) => page.locator(`${area} .hm-sw-viewer-header`).evaluate(element => { element.style.width = "" })
// Viewer header dropdown checked here (2026-10-21): the file-name list (.hm-sw-header__document-menu); the logo menu is gone.
const DOC_LIST = '.hm-sw-viewer-header .hm-sw-header__document-menu'
async function dropdownFonts(page, area, name, expected) {
const label = expected === DEFAULT ? 'Noto Sans KR' : 'Dotum'
await page.locator(`${area} .hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button`).click()
await page.waitForSelector(`${area} ${DOC_LIST}`)
const list = `${area} :is(${DOC_LIST}, .hm-sw-viewer-header .hm-sw-header__document-button)` // file name + file open surfaces
check(`${name}: 파일명 버튼 + 샘플 목록 = ${label}`, only(await fonts(page, list), expected), fmt(await fonts(page, list)))
await page.keyboard.press('Escape')
}
// Token → painted property of each dropdown part. 2026-10-21: the logo menu keeps the SWHeader `...` menu colors (object
// border, text, row hover tokens; its face #0e3d2f is a fixed value, checked below); the document list keeps its tokens.
const COLOR_TOKENS = [
['--hm-sw-header-document-menu-background', DOC_LIST, 'backgroundColor'],
['--hm-sw-header-document-menu-border-color', DOC_LIST, 'borderTopColor'],
['--hm-sw-header-title-color', `${DOC_LIST} .hm-sw-header__document-row:not([data-active]) .hm-sw-header__document-item-name`, 'color'],
['--hm-sw-header-document-item-hover-background', `${DOC_LIST} .hm-sw-header__document-row:not([data-active])`, 'backgroundColor', 'hover'],
]
async function dropdownColors(page, def, themed) {
// Paint of every part with its menu open (hovered first when the token is a hover state), next to the token value
// resolved in the same scope (a probe painted with var(token), so both are in the computed rgb() form).
const paint = async (area, scopeSelector) => {
const result = {}
for (const [token, part, property, hover] of COLOR_TOKENS) {
await page.locator(`${area} .hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button`).click()
const target = page.locator(`${area} ${part}`).first()
await target.waitFor()
if (hover) { await target.hover(); await page.waitForTimeout(120) }
result[token] = await target.evaluate((element, [token, property, scopeSelector]) => {
const probe = document.createElement('span'); probe.style.color = `var(${token})`
element.closest(scopeSelector).append(probe); const value = getComputedStyle(probe).color; probe.remove()
return { painted: getComputedStyle(element)[property], token: value }
}, [token, property, scopeSelector])
await page.mouse.move(2, 2)
await page.keyboard.press('Escape')
}
return result
}
const reference = await paint(def, '.hm-sw-viewer-header')
check('기본: 샘플 목록(SWHeader 파일명 목록 토큰) 배경·글자·Border·Hover = 기본 토큰 값', Object.values(reference).every(item => item.painted === item.token), JSON.stringify(reference))
// Themed: distinct colors set once on the outermost scope reach both menus; removed again afterwards.
const values = ['rgb(201, 11, 21)', 'rgb(202, 12, 22)', 'rgb(203, 13, 23)', 'rgb(204, 14, 24)']
const tokens = COLOR_TOKENS.map(item => item[0])
await page.locator(themed).evaluate((element, [tokens, values]) => tokens.forEach((token, i) => element.style.setProperty(token, values[i])), [tokens, values])
const themedPaint = await paint(themed, themed)
await page.locator(themed).evaluate((element, tokens) => tokens.forEach(token => element.style.removeProperty(token)), tokens)
check('뷰어 테마: 바깥 범위 토큰(배경·글자·Border·Hover)이 샘플 목록까지 상속', tokens.every((token, i) => themedPaint[token].painted === values[i]), JSON.stringify(themedPaint))
}
// ---------- 1. Theme inheritance Story ----------
{
const page = await newPage()
await open(page, 'sw-theme--theme-inheritance')
const scope = name => `[data-theme-case="${name}"]`
// Default (no theme): header, then its dropdowns and More. 2026-10-21: the header's dropdowns are the logo menu
// (파일 열기, SWHeader `...` menu) and the file-name sample list (SWHeader file-name list); the 샘플 menu button is gone.
const def = scope('default')
check('기본: 단독 SWViewerHeader = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-header`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-header`)))
await dropdownFonts(page, def, '기본', DEFAULT)
await openMore(page, def)
await page.waitForSelector(`${def} .hm-sw-viewer-menu[data-surface="beige"]`)
check('기본: More = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`)))
await page.keyboard.press('Escape')
await restoreWidth(page, def)
// Viewer, themed through <SWTheme style>.
const viewer = scope('viewer-themed')
check('뷰어 테마: SWViewerHeader 전체 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-header`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-header`)))
await dropdownFonts(page, viewer, '뷰어 테마', VERIFY)
// Colors / border / hover of the two dropdowns: the SWHeader default token values without a theme, and a token set
// through `style` on the outermost SW scope (here the themed <SWTheme>) reaches both menus. The colors are set only
// for this check and removed again (the Story itself themes the font only).
await dropdownColors(page, def, viewer)
await openMore(page, viewer)
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`)
check('뷰어 테마: More = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
// A submenu of More, if any, is a nested menu inside a nested menu.
const sub = page.locator(`${viewer} .hm-sw-viewer-menu[data-surface="beige"] [aria-haspopup="menu"]`).first()
if (await sub.count()) {
await sub.hover()
await page.waitForTimeout(150)
check('뷰어 테마: More 서브메뉴 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
}
await page.keyboard.press('Escape')
await restoreWidth(page, viewer)
const panels = await page.locator(`${viewer} .hm-sw-viewer-panel`).count()
check('뷰어 테마: SWViewerPanel(제목·입력·버튼·스위치·힌트) = Dotum', panels === 2 && only(await fonts(page, `${viewer} .hm-sw-viewer-panel`), VERIFY), `${panels} panels · ${fmt(await fonts(page, `${viewer} .hm-sw-viewer-panel`))}`)
await page.screenshot({ path: path.join(out, 'viewer-themed-more.png'), clip: await page.locator(viewer).boundingBox() })
// General mode: SWHeader (+ open layer dropdown, window-move tooltip), SWFooter (+ command line, guide tooltip).
const general = scope('general-themed')
const headerFonts = await fonts(page, `${general} .hm-sw-header`, '.hm-sw-header__motion-tooltip')
check('일반 테마: SWHeader + 열린 레이어 Dropdown = Dotum (창 이동 Tooltip 별도)', only(headerFonts, VERIFY), fmt(headerFonts))
const tooltip = await page.locator(`${general} .hm-sw-header__motion-tooltip`).evaluate(element => getComputedStyle(element).fontFamily)
check('일반 테마: 창 이동 Tooltip = Inter → 테마 글꼴', tooltip === `Inter, ${VERIFY}`, tooltip)
const layerMenu = await page.locator(`${general} .hm-sw-header`).getByText('A-WALL').count()
check('일반 테마: 레이어 Dropdown이 열린 상태(목록 행 A-WALL 표시)', layerMenu > 0, `${layerMenu} row`)
check('일반 테마: SWFooter = Dotum', only(await fonts(page, `${general} .hm-sw-footer`), VERIFY), fmt(await fonts(page, `${general} .hm-sw-footer`)))
check('일반 테마: 명령창(SWFooterCommandLine, 중첩 hm-sw-theme) = Dotum', only(await fonts(page, `${general} .hm-sw-footer-command`), VERIFY), fmt(await fonts(page, `${general} .hm-sw-footer-command`)))
await page.locator(`${general} .hm-sw-footer-add`).first().focus()
await page.waitForTimeout(150)
const guide = page.locator(`${general} .hm-sw-footer [role="tooltip"]`)
check('일반 테마: 명령 등록 안내(Tooltip) = Dotum', (await guide.count()) > 0 && only(await fonts(page, `${general} .hm-sw-footer [role="tooltip"]`), VERIFY), fmt(await fonts(page, `${general} .hm-sw-footer [role="tooltip"]`)))
// Portals.
const [plain, themed, insideSource] = await page.evaluate(() => {
const target = document.querySelector('[data-portal-target]')
const plain = target.querySelector('[data-portal="unthemed"]'), themed = target.querySelector('[data-portal="themed"]')
return [getComputedStyle(plain.querySelector('.hm-sw-viewer-button')).fontFamily, getComputedStyle(themed.querySelector('.hm-sw-viewer-button')).fontFamily,
!!document.querySelector('[data-theme-case="portal-source"] [data-portal]')]
})
check('Portal: DOM이 테마 영역 밖에 렌더링됨', !insideSource)
check('Portal: SWTheme만 감싸면 기본 글꼴(테마 미전달)', plain.startsWith('"Noto Sans KR"'), plain)
check('Portal: 같은 theme style의 SWTheme으로 감싸면 테마 적용', themed === VERIFY, themed)
// Areas.
const area = name => fonts(page, `${scope(name)} .hm-sw-viewer-hint`)
check('영역 A(Dotum)', only(await area('area-a'), VERIFY), fmt(await area('area-a')))
check('영역 B(기본) — 옆 영역 값이 섞이지 않음', only(await area('area-b'), DEFAULT), fmt(await area('area-b')))
const outer = await page.locator(`${scope('area-c')} > .hm-sw-viewer-hint`).evaluate(element => getComputedStyle(element).fontFamily)
check('영역 C 바깥(Dotum)', outer === VERIFY, outer)
check('영역 C 안쪽 SWTheme style(Malgun Gothic)만 안쪽에 적용', only(await area('area-c-nested'), NESTED), fmt(await area('area-c-nested')))
await page.screenshot({ path: path.join(out, 'theme-inheritance-full.png'), fullPage: true })
await page.close()
}
// ---------- 2. Regression: new cascade vs legacy cascade on every existing SW Story ----------
const here = path.dirname(fileURLToPath(import.meta.url))
const outermost = ':is(.hm-sw-header, .hm-sw-theme):not(:where(.hm-sw-header, .hm-sw-theme) *)'
const tokenBlocks = (await Promise.all(['sw.css', 'sw-viewer.css', 'sw-footer.css'].map(file => readFile(path.join(here, '../src/theme', file), 'utf8'))))
.map(text => {
const start = text.indexOf(`${outermost} {`)
const end = text.indexOf('\n}', start)
if (start < 0 || end < 0) throw new Error('token block not found')
return text.slice(text.indexOf('{', start), end + 2)
})
const legacy = tokenBlocks.map(block => `.hm-sw-header,\n.hm-sw-theme ${block}`).join('\n')
// Control (2026-10-20): the SAME token blocks on the SAME selector, i.e. the current cascade declared once more.
// Re-declaring the token blocks re-rasterizes a few gradient pixels (±1–2 per channel at the end of the Footer
// command row) even though no value changes, so the legacy shot is also compared with this control shot.
const control = tokenBlocks.map(block => `${outermost} ${block}`).join('\n')
const index = await (await fetch(`${storybook}/index.json`)).json()
const ids = Object.values(index.entries).filter(entry => entry.type === 'story' && /^sw-(header|popup|viewer|footer|home-menu)/.test(entry.id)).map(entry => entry.id)
const snapshot = page => page.evaluate(() => [...document.querySelectorAll('.hm-sw-header, .hm-sw-theme')].flatMap(root => [root, ...root.querySelectorAll('*')]).map(element => {
const style = getComputedStyle(element), rect = element.getBoundingClientRect()
return `${style.fontFamily}|${style.fontSize}|${style.color}|${style.backgroundImage.slice(0, 80)}|${Math.round(rect.x * 10)},${Math.round(rect.y * 10)},${Math.round(rect.width * 10)},${Math.round(rect.height * 10)}`
}))
const regression = []
for (const width of [1920, 1440, 1180, 900, 560]) for (const id of ids) {
if (width !== 1440 && !/responsive|viewer--viewer-header|footer--full-footer|overview/.test(id)) continue
const page = await newPage(width)
await open(page, id)
const a = await snapshot(page)
const shot = async () => PNG.sync.read(await page.screenshot({ fullPage: true, animations: 'disabled' }))
const shotA = await shot()
await page.waitForTimeout(150)
// Pixels that change between two shots of the SAME CSS (live Story content) are masked out as noise.
const shotA2 = await shot()
// Control: the current cascade declared again. Its computed values must equal the first state.
await page.addStyleTag({ content: control })
await page.waitForTimeout(150)
const c = await snapshot(page)
const shotC = await shot()
await page.waitForTimeout(150)
const shotC2 = await shot()
await page.addStyleTag({ content: legacy })
await page.waitForTimeout(150)
const b = await snapshot(page)
const shotB = await shot()
const changedValues = (x, y) => x.length === y.length ? x.filter((value, i) => value !== y[i]).length : -1
const changed = changedValues(a, b), controlChanged = changedValues(a, c)
let pixels = -1, noise = 0, controlPixels = -1
if ([shotA2, shotC, shotC2, shotB].every(item => item.width === shotA.width && item.height === shotA.height)) {
pixels = 0
controlPixels = 0
for (let i = 0; i < shotA.data.length; i += 4) {
const same = (x, y) => x[i] === y[i] && x[i + 1] === y[i + 1] && x[i + 2] === y[i + 2] && x[i + 3] === y[i + 3]
if (!same(shotA.data, shotC.data)) controlPixels++
// Live Story content changing at rest (before or after the control) is masked out as noise.
if (!same(shotA.data, shotA2.data) || !same(shotC.data, shotC2.data)) noise++
// First shot and control shot both render the unchanged cascade (re-raster artifacts appear only in the
// control; a transient relayout may appear in either one). The legacy cascade fails when its pixel matches
// neither of them exactly.
else if (!same(shotC.data, shotB.data) && !same(shotA.data, shotB.data)) pixels++
}
}
// Computed values: legacy vs first state (and control vs first state). Pixels: legacy vs first state and control.
regression.push({ id, width, elements: a.length, changedElements: changed, controlChangedElements: controlChanged, diffPixels: pixels,
controlRasterPixels: controlPixels, maskedNoisePixels: noise, result: changed === 0 && controlChanged === 0 && pixels === 0 ? 'PASS' : 'FAIL' })
await page.close()
}
// Negative control: the legacy cascade must reproduce the reported defect on the themed Story (the comparison above is
// only meaningful if injecting it really restores the old behavior).
{
const page = await newPage()
await open(page, 'sw-theme--theme-inheritance')
await page.addStyleTag({ content: legacy })
await page.waitForTimeout(150)
const viewer = '[data-theme-case="viewer-themed"]'
await openMore(page, viewer)
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`)
const more = await fonts(page, `${viewer} .hm-sw-viewer-menu`)
const command = await fonts(page, '[data-theme-case="general-themed"] .hm-sw-footer-command')
check('대조: 기존 구조 주입 시 More·명령창이 Noto로 되돌아감(결함 재현)', only(more, DEFAULT) && only(command, DEFAULT), `More ${fmt(more)} · 명령창 ${fmt(command)}`)
await page.close()
}
const failedRegression = regression.filter(item => item.result !== 'PASS')
check(`회귀: 기존 SW Story ${regression.length}개 화면 — 새 상속 구조 = 기존 구조(계산값·위치·픽셀)`, failedRegression.length === 0,
`${regression.length - failedRegression.length}/${regression.length}${failedRegression.length ? ` FAIL ${failedRegression.map(item => `${item.id}@${item.width}(${item.changedElements}/${item.diffPixels})`).join(', ')}` : ''}`)
check('Console / page error 없음', errors.length === 0, errors.slice(0, 5).join(' | '))
await browser.close()
await writeFile(path.join(out, 'theme-inheritance.json'), JSON.stringify({ storybook, results, regression, errors }, null, 2) + '\n')
const failed = results.filter(item => item.result !== 'PASS').length
console.log(`${results.length - failed}/${results.length} PASS`)
process.exit(failed ? 1 : 0)