200 lines
16 KiB
JavaScript
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)
|