229 lines
16 KiB
JavaScript
229 lines
16 KiB
JavaScript
// 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 { createRequire } from 'node:module'
|
||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||
import path from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
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 { 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 = []
|
||
const browser = await chromium.launch({ headless: true, 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.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' })
|
||
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'
|
||
|
||
// ---------- 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 sample dropdown and More.
|
||
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 page.locator(`${def} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click()
|
||
await page.waitForSelector(`${def} .hm-sw-viewer-menu`)
|
||
check('기본: Dropdown = 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 page.locator(`${def} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||
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')
|
||
|
||
// 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 page.locator(`${viewer} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click()
|
||
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu`)
|
||
check('뷰어 테마: Dropdown = 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 page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||
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')
|
||
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--default|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 page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||
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)
|