test(sw): align regression tests with current components

This commit is contained in:
shkim4 committed 2026-11-10 14:53:06 +09:00
1 parent 3cbe879910
commit b01ccaca01
5 files changed
+282 -31

No files matched your search

+47 -20
View File
@@ -1,17 +1,34 @@
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (property-fields). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
// Property fields (Figma 26:12388 ~ 26:12649): Default / real pointer Hover / Mouse Leave / On (editing, dropdown),
// selection callbacks, keyboard, Escape, external data, Disabled / ReadOnly, and the four panel states.
// 2026-11-10 (test alignment): the SW/Header › Property Fields and Property Panel demos this suite opened were removed in
// 5faa1f1 (approved). Sections 1–9 and the Header-hosted panel run on the test-only fixture fixtures/property-fields.tsx,
// which restores those demos' host data and host controls with the public API (as header-composition does); the four
// panel states run on the current Story sw-header--property-panel-compare (SW/Popup › 속성창). Every check is unchanged.
// Usage: node packages/sw/tests/property-fields.browser.mjs <evidence-dir> <label> [storybook-url]
// Storybook serves packages/sw/dist: build @hm-design/sw first.
import { mkdir, writeFile } from 'node:fs/promises'
// The fixture and Storybook use packages/sw/dist: build @hm-design/sw first.
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadPlaywright } from './playwright-runtime.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/property-fields-qa')
const label = process.argv[3] ?? 'run'
const base = process.argv[4] ?? 'http://127.0.0.1:6024'
const { chromium } = loadPlaywright()
await mkdir(out, { recursive: true })
// Test-only fixture page (built into the evidence directory, served only for this run).
const site = path.join(out, 'fixture')
await mkdir(site, { recursive: true })
await createRequire(import.meta.url)('esbuild').build({
entryPoints: [fileURLToPath(new URL('./fixtures/property-fields.tsx', import.meta.url))], bundle: true, outdir: site, format: 'esm', jsx: 'automatic', minify: true,
loader: { '.ttf': 'file', '.woff': 'file', '.woff2': 'file', '.svg': 'file', '.png': 'file' }, define: { 'process.env.NODE_ENV': '"production"' }, logLevel: 'warning',
})
await writeFile(path.join(site, 'index.html'), '<!doctype html><html lang="ko"><head><meta charset="utf-8"><link rel="icon" href="data:,"><link rel="stylesheet" href="property-fields.css"></head><body style="margin:0"><div id="root"></div><script type="module" src="property-fields.js"></script></body></html>')
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.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(site, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(site, 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 fixture = `http://127.0.0.1:${server.address().port}/`
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: 1400, height: 1000 }, deviceScaleFactor: 2 })
const report = { label, checks: [], errors: [] }
@@ -19,7 +36,7 @@ page.on('pageerror', e => report.errors.push(e.message)); page.on('console', m =
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
const shot = async (name, locator) => writeFile(path.join(out, `${label}-${name}.png`), await (locator ?? page).screenshot())
await page.goto(`${base}/iframe.html?id=sw-header--property-fields&viewMode=story`)
await page.goto(fixture)
await page.locator('[data-fields="live"] .hm-sw-property').waitFor(); await page.evaluate(() => document.fonts.ready)
const live = page.locator('[data-fields="live"]')
const row = id => live.locator(`.hm-sw-property__row[data-property-id="${id}"]`)
@@ -35,9 +52,13 @@ for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thi
const s = await style(field(id), 'borderTopColor', 'backgroundColor', 'borderTopLeftRadius')
check(`Default ${id}: 200 x 28, radius 2, no border / surface`, s.w === 200 && s.h === 28 && s.borderTopColor === CLEAR && s.backgroundColor === CLEAR && s.borderTopLeftRadius === '2px', s)
}
// 2026-11-10: since the user-approved 2026-10-21 design review (2fc1b3e) 선가중치 is no longer the 41px hug field but the
// 200 x 28 box like 선축적, and the 투명도 value is its own 28px box before the gauge (SWHeaderPropertyPanel.tsx). The
// same properties are checked on the current fields: size, padding (7px incl. no border), radius, no border / surface.
const compactField = id => id === 'weight' ? field('weight') : row(id).locator('.hm-sw-property__slider-value')
for (const id of ['weight', 'opacity']) {
const s = await style(row(id).locator('.hm-sw-property__compact'), 'borderTopColor', 'backgroundColor', 'paddingLeft', 'paddingRight')
check(`Default ${id}: compact 28px hug field (pl 6 / pr 4 incl. border), no border`, s.h === 28 && s.w < 60 && s.borderTopColor === CLEAR && s.paddingLeft === '5px' && s.paddingRight === '3px', s)
const s = await style(compactField(id), 'borderTopColor', 'backgroundColor', 'paddingLeft', 'paddingRight', 'borderTopLeftRadius')
check(`Default ${id}: ${id === 'weight' ? '200 x 28 box' : '28px slider value box'} (pl / pr 7), radius 2, no border / surface`, s.h === 28 && (id === 'weight' ? s.w === 200 : s.w < 200) && s.borderTopColor === CLEAR && s.backgroundColor === CLEAR && s.paddingLeft === '7px' && s.paddingRight === '7px' && s.borderTopLeftRadius === '2px', s)
}
const typo = await row('scale').evaluate(r => { const v = getComputedStyle(r.querySelector('.hm-sw-header__input')), u = getComputedStyle(r.querySelector('.hm-sw-property__unit')); return { value: [v.color, v.fontSize], unit: [u.color, u.fontSize] } })
check('Typography: value #E60403 14px, unit #000 12px', typo.value.join() === 'rgb(230, 4, 3),14px' && typo.unit.join() === 'rgb(0, 0, 0),12px', typo)
@@ -56,9 +77,9 @@ for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thi
check(`Mouse Leave ${id}: back to Default`, left.borderTopColor === CLEAR, left)
}
for (const id of ['weight', 'opacity']) {
const c = row(id).locator('.hm-sw-property__compact')
const c = compactField(id)
await c.hover(); const s = await style(c, 'borderTopColor'); await page.mouse.move(1, 1); const l = await style(c, 'borderTopColor')
check(`Hover / Leave ${id} compact`, s.borderTopColor === HOVER && l.borderTopColor === CLEAR, { hover: s.borderTopColor, leave: l.borderTopColor })
check(`Hover / Leave ${id} ${id === 'weight' ? 'box' : 'slider value box'}`, s.borderTopColor === HOVER && l.borderTopColor === CLEAR, { hover: s.borderTopColor, leave: l.borderTopColor })
}
await field('scale').hover(); await shot('hover-scale', field('scale')); await page.mouse.move(1, 1)
@@ -78,8 +99,9 @@ check('Enter: 선축적 2 → validated, 4 decimals committed', (await log()).in
await scale.fill('7'); await page.keyboard.press('Escape')
check('Escape: draft cancelled (2.0000 restored, no callback)', await scale.inputValue() === '2.0000' && !(await log()).includes('7'), await scale.inputValue())
const weight = row('weight').locator('input')
await weight.click(); const w = await style(weight, 'backgroundColor')
check('On(편집) 선가중치 compact: #0F0D0A', w.backgroundColor === 'rgb(15, 13, 10)', w)
// 2fc1b3e (2026-10-21): the editing face is on the 200px box (as 선축적), no longer on the hug field's input.
await weight.click(); const w = await style(field('weight'), 'backgroundColor')
check('On(편집) 선가중치 box: #0F0D0A', w.backgroundColor === 'rgb(15, 13, 10)', w)
await shot('on-weight', row('weight'))
await weight.fill('-1'); await page.keyboard.press('Enter')
check('Invalid 선가중치 -1 (min 0): restored, no callback', await weight.inputValue() === '0.25' && !(await log()).includes('weight, -1'), await weight.inputValue())
@@ -87,10 +109,11 @@ await weight.fill('0.5'); await page.keyboard.press('Enter')
check('선가중치 0.5 committed', (await log()).includes('property(weight, 0.5)'), await log())
await page.mouse.click(1, 1)
// 4. No invented On: 하이퍼링크 (text) focus keeps the Hover look only; 재료 (computed) is read-only.
// 4. 하이퍼링크 (editable text): since 6d46289 (2026-10-22, approved) its editing face is the numbers' On (#0F0D0A,
// Hover border); 재료 (computed) stays read-only without On.
await row('link').locator('input').click()
const link = await style(field('link'), 'backgroundColor', 'borderTopColor')
check('하이퍼링크 focus: no On surface (Figma has no On), editable', link.backgroundColor === CLEAR && link.borderTopColor === HOVER, link)
check('하이퍼링크 focus: editing face #0F0D0A like numbers (6d46289), Hover border, editable', link.backgroundColor === 'rgb(15, 13, 10)' && link.borderTopColor === HOVER, link)
await row('link').locator('input').fill('http://a'); await page.keyboard.press('Enter')
check('하이퍼링크 commit via host callback', (await log()).includes('property(link, http://a)'), await log())
await row('material').locator('input').click()
@@ -132,16 +155,20 @@ check('Keyboard: ArrowDown opens + focuses list, Escape closes to trigger', focu
await layerTrigger.click(); await page.mouse.click(1300, 900)
check('Outside click closes', await drop.count() === 0, null)
// 6. Line style (26:12388 / 26:12404): 466 = 182 + 142 + 142, right-aligned, preview follows the value.
// 6. Line style (26:12388 / 26:12404): right-aligned, preview follows the value. Since 2fc1b3e (2026-10-21 design
// review, DESIGN_RULES 속성창 입력) the property window list is the line type column only (preview + name; type column
// 182px, growing to 240px only for wider font names); weights / scale stay in the SWHeader line style field.
await field('line').locator('.hm-sw-property__trigger').click()
f = await style(field('line'), 'backgroundColor'); d = await style(drop.locator('.hm-sw-header__line-drop'))
const cols = await drop.locator('.hm-sw-header__line-column').evaluateAll(es => es.map(e => +e.getBoundingClientRect().width.toFixed(2)))
const unitOn = await style(field('line').locator('.hm-sw-property__unit'), 'color')
check('Line style On: #211D13, (ByL) #D4CBBD, 3 columns, right edges meet (or shifted to the 4px screen edge when no room)', f.backgroundColor === 'rgb(33, 29, 19)' && unitOn.color === 'rgb(212, 203, 189)' && cols.length === 3 && (Math.abs(d.x + d.w - (f.x + f.w)) < 0.5 || d.x === 4) && Math.abs(d.y - (f.y + f.h)) < 0.5, { f, d, cols })
check('Line style drop 466 x 190 (type column grows only for wider font names)', d.w >= 466 && d.h === 190, { w: d.w, h: d.h, cols })
const typeOnly = await drop.locator('.hm-sw-header__line-column').evaluateAll(es => es.length === 1 && es[0].classList.contains('hm-sw-header__line-column--type'))
check('Line style On: #211D13, (ByL) #D4CBBD, 1 column (line types), right edges meet (or shifted to the 4px screen edge when no room)', f.backgroundColor === 'rgb(33, 29, 19)' && unitOn.color === 'rgb(212, 203, 189)' && cols.length === 1 && typeOnly && (Math.abs(d.x + d.w - (f.x + f.w)) < 0.5 || d.x === 4) && Math.abs(d.y - (f.y + f.h)) < 0.5, { f, d, cols, typeOnly })
check('Line style drop 182–240 x 190 (type column grows only for wider font names)', d.w >= 182 && d.w <= 240 && Math.abs(d.w - cols[0]) < 0.5 && d.h === 190, { w: d.w, h: d.h, cols })
await shot('on-line', page)
await drop.getByRole('button', { name: 'ByBlock', exact: true }).nth(1).click()
check('Weight column: callback only, stays open', (await log()).includes('lineWeight(w-byblock)') && await drop.count() === 1, await log())
// No weight column: the only ByBlock entry is the line type, and no weight / scale callback was called.
const byBlock = await drop.getByRole('button', { name: 'ByBlock', exact: true }).count()
check('Weight column: absent (ByBlock only as a line type), no lineWeight / lineScale callback, list stays open', byBlock === 1 && !/lineWeight|lineScale/.test(await log()) && await drop.count() === 1, { byBlock, log: await log() })
await drop.getByRole('button', { name: 'CENTER' }).click()
const segs = await field('line').locator('.hm-sw-header__line-preview > i').count()
check('Line type select: property(line, CENTER), closes, preview = CENTER pattern (4 segments)', (await log()).includes('property(line, CENTER)') && await drop.count() === 0 && segs === 4, { segs })
@@ -212,7 +239,7 @@ const want = { none: [314, 256], all: [314, 361], circle: [314, 658], expanded:
check('4 panel states: 314 x 256 / 361 / 658 / 754', sizes.every(([k, w, h]) => w === want[k][0] && h === want[k][1]), sizes)
await shot('compare', page.locator('.sw-story-compare'))
// The header-hosted panel: layer field opens its list over the header page as well.
await page.goto(`${base}/iframe.html?id=sw-header--property-panel&viewMode=story`)
await page.goto(`${fixture}?view=header`)
await page.locator('.hm-sw-header').waitFor()
await page.getByRole('button', { name: '원 1개', exact: true }).click()
await page.locator('.hm-sw-header [data-property-panel-trigger]').click()
@@ -223,7 +250,7 @@ const stillOpen = await page.locator('.hm-sw-property').count()
check('Header-hosted panel: layer list attached; Escape closes list only', hosted && Math.abs(hosted.right) < 0.5 && Math.abs(hosted.top) < 0.5 && stillOpen === 1, { hosted, stillOpen })
check('no console / page errors', report.errors.length === 0, report.errors)
await writeFile(path.join(out, `${label}-result.json`), JSON.stringify(report, null, 2))
await browser.close()
await browser.close(); server.close()
const failed = report.checks.filter(c => c.status === 'FAIL').length
console.log(`${report.checks.length - failed}/${report.checks.length} PASS`)
process.exit(failed ? 1 : 0)