Files
HMDesign/packages/sw/tests/followup-four.browser.mjs
T

200 lines
16 KiB
JavaScript

// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (property-bar, additional-hover). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
// Usage: node packages/sw/tests/followup-four.browser.mjs <evidence-dir> [storybook-url] (Storybook uses packages/sw/dist: build first)
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { loadPlaywright } from './playwright-runtime.mjs'
const out = path.resolve(process.argv[2])
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
const { chromium } = loadPlaywright()
await mkdir(out, { recursive: true })
const browser = await chromium.launch({ 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 = { 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, JSON.stringify(data)) }
const goto = async (id, w = 1920) => {
await page.setViewportSize({ width: w, height: 1000 })
await page.goto(`${base}/iframe.html?id=sw-header--${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)
}
// Pixel sampler on a screenshot buffer (decoded in page canvas).
const pixels = async (buf, pts) => page.evaluate(async ([b64, pts]) => {
const i = new Image(); i.src = 'data:image/png;base64,' + b64; await i.decode()
const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0)
return pts.map(([px, py]) => [...x.getImageData(px, py, 1, 1).data].slice(0, 3))
}, [buf.toString('base64'), pts])
const near = (a, b, t = 12) => a.every((v, k) => Math.abs(v - b[k]) <= t)
// ---- A. Property bar
await goto('property-bar')
const toggleBar = page.locator('.hm-sw-header [data-property-bar-trigger], .hm-sw-header .hm-sw-header__toggle').filter({ hasText: '속성Bar' }).first()
await toggleBar.click()
const barGeo = () => page.evaluate(() => {
const bar = document.querySelector('.hm-sw-header .hm-sw-property-bar'), r = bar.getBoundingClientRect()
const fields = [...bar.querySelectorAll('.hm-sw-property-bar__field')], last = fields.at(-1).getBoundingClientRect()
const s = getComputedStyle(bar), header = document.querySelector('.hm-sw-header').getBoundingClientRect()
return { left: r.left, right: r.right, width: r.width, height: r.height, lastFieldRight: last.right, scrollW: bar.scrollWidth, clientW: bar.clientWidth,
borderBottom: s.borderBottomWidth + ' ' + s.borderBottomColor, radius: s.borderBottomLeftRadius + '/' + s.borderBottomRightRadius, bg: s.backgroundColor,
headerH: header.height, headerRight: header.right, fieldCount: fields.length }
})
let g = await barGeo()
report.data.barDefault = g
check('A1 bar ends after last field (padding 6 + border 1)', Math.abs(g.right - g.lastFieldRight - 7) <= 1 && g.width < 1222, g)
check('A2 height 28 / header 49 / radius & border kept', g.height === 28 && g.headerH === 49 && g.radius === '6px/6px' && g.borderBottom.startsWith('1px'), g)
await page.locator('.hm-sw-header .hm-sw-property-bar').screenshot({ path: path.join(out, 'A-bar-1920.png') })
// Background and bottom line end at the same x: sample 2px inside and 2px outside the right edge at the bottom border row.
const barTop = await page.evaluate(() => document.querySelector('.hm-sw-header .hm-sw-property-bar').getBoundingClientRect().top)
const shot = await page.screenshot({ clip: { x: Math.floor(g.right) - 20, y: Math.floor(barTop), width: 40, height: 28 } })
await page.screenshot({ path: path.join(out, 'A-bar-right-end.png'), clip: { x: Math.floor(g.right) - 60, y: Math.floor(barTop) - 10, width: 120, height: 40 } })
const ex = g.right - (Math.floor(g.right) - 20)
const [inBg, outBg, inLine, outLine] = await pixels(shot, [[Math.floor(ex) - 4, 22], [Math.ceil(ex) + 3, 22], [Math.floor(ex) - 8, 27], [Math.ceil(ex) + 3, 27]])
check('A3 background and bottom line end together; nothing brown beyond', near(inBg, [0x27, 0x1f, 0x13], 10) && !near(outBg, [0x27, 0x1f, 0x13], 10) && !near(outLine, inLine, 10), { inBg, outBg, inLine, outLine })
for (let n = 0; n < 6; n++) await page.getByRole('button', { name: 'Bar 필드 추가' }).click()
g = await barGeo(); report.data.barGrown = g
check('A4 bar grows with added fields, none clipped', g.fieldCount === 10 && Math.abs(g.right - g.lastFieldRight - 7) <= 1 && g.scrollW <= g.clientW + 1, g)
for (let n = 0; n < 14; n++) await page.getByRole('button', { name: 'Bar 필드 추가' }).click()
g = await barGeo(); report.data.barOverflow = g
const lastVisible = await page.evaluate(() => { const bar = document.querySelector('.hm-sw-header .hm-sw-property-bar'); bar.scrollLeft = bar.scrollWidth; const f = [...bar.querySelectorAll('.hm-sw-property-bar__field')].at(-1).getBoundingClientRect(), r = bar.getBoundingClientRect(); return f.right <= r.right + 0.5 })
check('A5 longer than space: capped to header edge, scroll reaches last field', g.right <= g.headerRight + 0.5 && g.scrollW > g.clientW && lastVisible, { ...g, lastVisible })
await page.getByRole('button', { name: 'Bar 객체 교체' }).click()
g = await barGeo(); report.data.barReplaced = g
check('A6 bar shrinks back for a 1-field object', g.fieldCount === 1 && Math.abs(g.right - g.lastFieldRight - 7) <= 1, g)
for (const w of [1440, 1080, 768]) {
await goto('property-bar', w)
const t = page.locator('.hm-sw-header .hm-sw-header__toggle').filter({ hasText: '속성Bar' }).first()
if (await t.isVisible()) await t.click(); else { await page.locator('.hm-sw-header__more-trigger').click(); await page.locator('.hm-sw-header__more-item').filter({ hasText: '속성Bar' }).click() }
const gg = await barGeo(); report.data[`bar${w}`] = gg
const overflowX = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)
check(`A7 width ${w}: bar within header, height 28, no page scroll`, gg.right <= gg.headerRight + 0.5 && gg.height === 28 && gg.headerH === 49 && !overflowX, { ...gg, overflowX })
}
// ---- B. Tool icon
// The four states share one Story since 2026-10-19; each check is scoped to its panel.
await goto('property-panel-compare')
const tool = page.locator('[data-compare="all"] .hm-sw-property__tool')
const toolInfo = await tool.evaluate(e => { const s = getComputedStyle(e), imgs = [...e.querySelectorAll('img')].map(i => ({ cls: i.className, w: i.getBoundingClientRect().width, h: i.getBoundingClientRect().height, src: i.getAttribute('src').slice(0, 60) })); return { border: s.borderTopWidth + ' ' + s.borderTopColor, outline: s.outlineStyle, bg: s.backgroundColor, w: e.getBoundingClientRect().width, h: e.getBoundingClientRect().height, imgs } })
report.data.tool = toolInfo
check('B1 no CSS border/outline/background on tool button', toolInfo.border.startsWith('0px') && toolInfo.outline === 'none' && toolInfo.bg === 'rgba(0, 0, 0, 0)', toolInfo)
check('B2 images 29 x 27.13 equal the button', toolInfo.imgs.every(i => i.w === 29 && Math.abs(i.h - 27.13) < 0.02), toolInfo.imgs)
const toolCorners = async name => {
const box = await tool.boundingBox()
const clip = { x: Math.floor(box.x) - 3, y: Math.floor(box.y) - 3, width: 36, height: 34 }
const b = await page.screenshot({ clip, scale: 'css' })
await page.screenshot({ path: path.join(out, `B-tool-${name}.png`), clip })
// Any near-white pixel within the 29x27 button area means a light frame.
const pts = []; for (let y = 3; y < 30; y++) for (let x = 3; x < 32; x++) pts.push([x, y])
const px = await pixels(b, pts)
const white = px.filter(p => p.every(v => v >= 235)).length
const outer = [px[0], px[28], px[pts.length - 29], px[pts.length - 1]] // four corner pixels
return { white, outer }
}
const def = await toolCorners('default')
await tool.hover(); const hov = await toolCorners('hover')
await tool.click(); await page.mouse.move(1, 1); const act = await toolCorners('active')
const pressed = await tool.getAttribute('aria-pressed')
report.data.toolPixels = { def, hov, act, pressed }
check('B3 no white frame pixels (default/hover/active)', def.white === 0 && hov.white === 0 && act.white === 0, { def: def.white, hov: hov.white, act: act.white })
check('B4 active toggles (aria-pressed)', pressed === 'true', { pressed })
// ---- C/D. Dropdown open: no trigger hover, inner hover kept
await goto('additional-hover')
const ringOpacity = sel => page.evaluate(s => { const e = document.querySelector(s); return { after: getComputedStyle(e, '::after').opacity, color: getComputedStyle(e).color, expanded: e.dataset.expanded ?? null } }, sel)
for (const menu of ['layer', 'color', 'text-style', 'dimension-style', 'line-style']) {
const sel = `.hm-sw-header .hm-sw-header__field[data-menu="${menu}"]`
const field = page.locator(sel).first()
await page.mouse.move(1, 1)
await field.hover(); const closedHover = await ringOpacity(sel)
await field.click(); await page.mouse.move(1, 1)
const openIdle = await ringOpacity(sel)
await field.hover(); const openHover = await ringOpacity(sel)
const shotIdle = null
await page.screenshot({ path: path.join(out, `C-${menu}-open-hover.png`), clip: { ...(await field.boundingBox()), x: (await field.boundingBox()).x - 4, y: 0, width: (await field.boundingBox()).width + 8, height: 60 } })
// Inner list row + detail hover
const panel = page.locator('.hm-sw-header [data-open="true"]').first()
const rows = panel.locator('.hm-sw-header__menu-row:not([data-active="true"]):not([data-disabled]), .hm-sw-header__layer-row:not([data-active="true"]), .hm-sw-header__color-mode:not(:disabled)')
let rowChanged = null
for (let k = 0; k < Math.min(2, await rows.count()); k++) {
const row = rows.nth(k); await page.mouse.move(1, 1)
const st = e => { const t = e.querySelector('.hm-sw-header__menu-text') ?? e; return getComputedStyle(e).backgroundColor + '|' + getComputedStyle(t).color }
const b0 = await row.evaluate(st)
await row.hover(); const b1 = await row.evaluate(st)
rowChanged = rowChanged || b0 !== b1
}
const detail = panel.locator('.hm-sw-header__menu-detail').first()
let detailChanged = null
if (await detail.count()) {
await page.mouse.move(1, 1)
const d0 = await detail.evaluate(e => getComputedStyle(e).backgroundColor + '|' + getComputedStyle(e).boxShadow + '|' + getComputedStyle(e).color)
await detail.hover(); const d1 = await detail.evaluate(e => getComputedStyle(e).backgroundColor + '|' + getComputedStyle(e).boxShadow + '|' + getComputedStyle(e).color)
detailChanged = d0 !== d1
}
await page.keyboard.press('Escape'); await page.mouse.move(1, 1)
const closedAgain = await ringOpacity(sel)
await field.hover(); const restored = await ringOpacity(sel)
await page.mouse.move(1, 1)
const res = { closedHover, openIdle, openHover, closedAgain, restored, rowChanged, detailChanged }
report.data[`dropdown-${menu}`] = res
check(`C ${menu}: open+hover == open idle`, openIdle.expanded === 'true' && openHover.after === openIdle.after && openHover.color === openIdle.color, res)
check(`C ${menu}: hover restored after close`, closedAgain.expanded === null && restored.after === '1' && closedHover.after === '1' && restored.color === closedHover.color, res)
check(`D ${menu}: inner row hover kept / detail hover kept`, rowChanged !== false && detailChanged !== false, { rowChanged, detailChanged })
}
// Focus-visible on an open trigger still shows the outline
{
const sel = '.hm-sw-header .hm-sw-header__field[data-menu="color"]'
await page.locator(sel).first().focus(); await page.keyboard.press('Enter'); await page.keyboard.press('Shift+Tab').catch(() => {})
}
// ---- E. Gauge
await goto('property-panel-compare')
const range = page.locator('[data-compare="circle"] .hm-sw-property input[type="range"]')
const gaugeState = () => range.evaluate(e => ({ value: e.value, fill: e.style.getPropertyValue('--hm-sw-range-fill'), track: getComputedStyle(e).getPropertyValue('--hm-sw-property-range-track').trim() }))
const gaugePixels = async name => {
const b = await range.boundingBox()
const clip = { x: Math.floor(b.x), y: Math.floor(b.y), width: Math.ceil(b.width), height: Math.ceil(b.height) }
const buf = await page.screenshot({ clip })
await page.screenshot({ path: path.join(out, `E-gauge-${name}.png`), clip })
const st = await gaugeState(), f = Number(st.fill), cx = 7 + (clip.width - 14) * f, mid = Math.floor(clip.height / 2)
const [left, right] = await pixels(buf, [[Math.max(2, Math.floor(cx - 10)), mid], [Math.min(clip.width - 3, Math.ceil(cx + 10)), mid]])
return { ...st, left, right, width: clip.width }
}
let gs = await gaugePixels('initial'); report.data.gaugeInitial = gs
const isRed = p => p[0] > 150 && p[1] < 90 && p[2] < 90, isBeige = p => p[0] > 190 && p[1] > 180 && p[2] > 160 && p[0] - p[2] > 15
check('E1 initial 0.25: red left of thumb, beige right', gs.value === '0.25' && Math.abs(Number(gs.fill) - 0.25) < 1e-9 && isRed(gs.left) && isBeige(gs.right), gs)
await range.focus(); for (let n = 0; n < 40; n++) await page.keyboard.press('ArrowRight')
gs = await gaugePixels('keyboard'); report.data.gaugeKeyboard = gs
check('E2 keyboard ArrowRight x40 -> 0.65, fill follows', gs.value === '0.65' && Math.abs(Number(gs.fill) - 0.65) < 1e-9 && isRed(gs.left) && isBeige(gs.right), gs)
await page.keyboard.press('Enter')
await page.waitForFunction(() => document.querySelector('[data-compare="circle"] .hm-sw-property [data-property-id="opacity"] .hm-sw-header__input')?.value === '0.65', null, { timeout: 2000 }).catch(() => {})
const committed = await page.locator('[data-compare="circle"] [data-property-id="opacity"] .hm-sw-header__input').inputValue().catch(() => null)
report.data.gaugeCommitted = committed
check('E3 commit updates the number input (callback path)', committed === '0.65', { committed })
await page.keyboard.press('End'); gs = await gaugePixels('max'); report.data.gaugeMax = gs
check('E4 max -> fill 1', gs.value === '1' && Number(gs.fill) === 1 && isRed(gs.left), gs)
await page.keyboard.press('Home'); gs = await gaugePixels('min'); report.data.gaugeMin = gs
check('E5 min -> fill 0, beige right', gs.value === '0' && Number(gs.fill) === 0 && isBeige(gs.right), gs)
// External value: type into the numeric input and commit
const num = page.locator('[data-compare="circle"] [data-property-id="opacity"] .hm-sw-header__input')
await num.click(); await num.fill('0.5'); await num.press('Enter'); await page.mouse.move(1, 1)
gs = await gaugeState(); report.data.gaugeExternal = gs
check('E6 value changed elsewhere syncs gauge', gs.value === '0.5' && Number(gs.fill) === 0.5, gs)
await page.locator('[data-compare="circle"] .hm-sw-property').screenshot({ path: path.join(out, 'E-panel-circle.png') })
// ---- F. Panel four states render
await goto('property-panel-compare')
for (const id of ['property-panel-none', 'property-panel-all', 'property-panel-circle', 'property-panel-expanded']) {
const panel = page.locator(`[data-compare="${id.replace('property-panel-', '')}"] .hm-sw-property`)
const sz = await panel.evaluate(e => { const r = e.getBoundingClientRect(); return [r.width, r.height] })
report.data[id] = sz
await panel.screenshot({ path: path.join(out, `F-${id}.png`) })
check(`F ${id} renders`, sz[0] === 314 && sz[1] > 200, sz)
}
check('G no page/console errors', report.errors.length === 0, report.errors)
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2))
await browser.close()
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)