Files
HMDesign/packages/sw/tests/property-fields.browser.mjs
T

257 lines
24 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.
// 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]
// 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: [] }
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 shot = async (name, locator) => writeFile(path.join(out, `${label}-${name}.png`), await (locator ?? page).screenshot())
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}"]`)
const field = id => row(id).locator('.hm-sw-property__value').first()
const log = () => page.locator('[data-property-log]').textContent()
const HOVER = 'rgba(33, 29, 19, 0.4)', CLEAR = 'rgba(0, 0, 0, 0)'
const style = (locator, ...props) => locator.evaluate((e, props) => { const s = getComputedStyle(e), r = e.getBoundingClientRect(); return Object.assign({ x: r.x, y: r.y, w: +r.width.toFixed(2), h: +r.height.toFixed(2) }, ...props.map(p => ({ [p]: s[p] }))) }, props)
const textBox = async id => row(id).locator('.hm-sw-header__input, .hm-sw-property__text').first().evaluate(e => { const r = e.getBoundingClientRect(); return [r.x, r.y] })
await page.mouse.move(1, 1)
// 1. Geometry (Default): 200 x 28 boxes, 28px compact (선가중치 / 투명도 value), radius 2, no border, no surface.
for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thickness', 'material']) {
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(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)
await shot('default', live.locator('.hm-sw-property'))
// 2. Real pointer Hover → border only, value does not move; Mouse Leave → Default.
for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thickness', 'material']) {
const before = await textBox(id)
await field(id).hover()
const s = await style(field(id), 'borderTopColor', 'backgroundColor')
const after = await textBox(id)
const extra = id === 'color' ? await style(row(id).locator('.hm-sw-property__color-value'), 'borderRightColor') : {}
check(`Hover ${id}: rgba(33,29,19,.4) border, 28px kept, value not moved${id === 'color' ? ', divider shown' : ''}`, s.borderTopColor === HOVER && s.h === 28 && s.backgroundColor === CLEAR && before.join() === after.join() && (id !== 'color' || extra.borderRightColor === HOVER), { ...s, before, after, ...extra })
await page.mouse.move(1, 1)
const left = await style(field(id), 'borderTopColor')
check(`Mouse Leave ${id}: back to Default`, left.borderTopColor === CLEAR, left)
}
for (const id of ['weight', 'opacity']) {
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} ${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)
// 3. On (editing): numbers only. #0F0D0A, unit white, outlined value, real input focus; Enter commits (4 decimals).
for (const id of ['scale', 'thickness']) {
await row(id).locator('.hm-sw-property__unit').click()
const s = await style(field(id), 'backgroundColor', 'borderTopColor')
const u = await style(row(id).locator('.hm-sw-property__unit'), 'color')
const input = await row(id).locator('input').evaluate(e => ({ focused: document.activeElement === e, caret: getComputedStyle(e).caretColor, shadow: getComputedStyle(e).textShadow !== 'none' }))
check(`On(편집) ${id}: #0F0D0A, border, unit #FFF, focused input with red caret + outline`, s.backgroundColor === 'rgb(15, 13, 10)' && s.borderTopColor === HOVER && u.color === 'rgb(255, 255, 255)' && input.focused && input.caret === 'rgb(230, 4, 3)' && input.shadow && s.h === 28, { ...s, unit: u.color, ...input })
await shot(`on-${id}`, field(id))
await page.keyboard.press('Escape')
}
const scale = row('scale').locator('input')
await scale.click(); await scale.fill('2'); await page.keyboard.press('Enter')
check('Enter: 선축적 2 → validated, 4 decimals committed', (await log()).includes('property(scale, 2.0000)') && await scale.inputValue() === '2.0000', await log())
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')
// 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())
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. 하이퍼링크 (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: 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()
const mat = await style(field('material'), 'backgroundColor')
check('재료: read-only, no On', mat.backgroundColor === CLEAR && await row('material').locator('input').evaluate(e => e.readOnly), mat)
await page.mouse.click(1, 1)
// 5. Layer dropdown (26:12265 / 26:12291): #211D13, 234px list right-aligned under the field, rows 30, footer 40.
const layerTrigger = field('layer').locator('.hm-sw-property__trigger')
await layerTrigger.click()
const drop = page.locator('.hm-sw-property__drop')
let f = await style(field('layer'), 'backgroundColor', 'borderTopColor'), d = await style(drop.first())
const rows = await drop.locator('.hm-sw-header__layer-row').count()
const rowH = await style(drop.locator('.hm-sw-header__layer-row').first()), foot = await style(drop.locator('.hm-sw-header__layer-footer'))
check('Layer On: #211D13 field, 234 wide list, right edges meet, starts at field bottom', f.backgroundColor === 'rgb(33, 29, 19)' && d.w === 234 && Math.abs(d.x + d.w - (f.x + f.w)) < 0.5 && Math.abs(d.y - (f.y + f.h)) < 0.5, { f, d })
check('Layer list rows 30px, footer 40px, height = rows*30+40', rowH.h === 30 && foot.h === 40 && Math.abs(d.h - (rows * 30 + 40)) < 0.5, { rows, row: rowH.h, footer: foot.h, h: d.h })
check('Layer current row marked (0_Layer_Cludmark)', await drop.locator('[aria-current="true"]').textContent() === '0_Layer_Cludmark', null)
await shot('on-layer', page)
await drop.locator('.hm-sw-header__layer-row').nth(3).locator('.hm-sw-header__layer-toggle').first().click()
check('Bulb toggle: callback only, list stays open, row not selected', (await log()).includes('layer.visible(Sample-Wall, true)') && await drop.count() === 1 && !(await log()).includes('property(layer, Sample-Wall)'), await log())
await drop.getByRole('button', { name: 'CC-D GNL', exact: true }).click()
check('Row select: property(layer, CC-D GNL), closes, field shows value', (await log()).includes('property(layer, CC-D GNL)') && await drop.count() === 0 && (await field('layer').textContent()).includes('CC-D GNL'), await log())
check('Close keeps the value and focus returns to the field', await layerTrigger.evaluate(e => document.activeElement === e), null)
// Mixed (다양함): no current row; host switch to single value.
await field('layer-mixed').locator('.hm-sw-property__trigger').click()
check('다양함: text 다양함, no current row in list', (await field('layer-mixed').locator('.hm-sw-property__trigger').textContent()) === '다양함' && await drop.locator('[aria-current="true"]').count() === 0, null)
await shot('on-layer-mixed', page)
await page.keyboard.press('Escape')
check('Escape closes the layer list; panel stays', await drop.count() === 0 && await live.locator('.hm-sw-property').count() === 1, null)
await page.getByRole('button', { name: '레이어 다양함 ↔ 단일' }).click()
check('Host data: 다양함 → 단일 값 (0_Layer_Cludmark)', (await field('layer-mixed').textContent()) === '0_Layer_Cludmark', await field('layer-mixed').textContent())
await page.getByRole('button', { name: '레이어 외부 값 변경' }).click()
check('External value change shown (CC-D GNL)', (await field('layer').textContent()) === 'CC-D GNL', null)
// Keyboard: Enter opens, ArrowDown moves into the list, Escape closes back to the trigger.
await layerTrigger.focus(); await page.keyboard.press('ArrowDown')
const focusedInList = await page.evaluate(() => !!document.activeElement?.closest('.hm-sw-property__drop'))
await page.keyboard.press('Escape')
check('Keyboard: ArrowDown opens + focuses list, Escape closes to trigger', focusedInList && await drop.count() === 0 && await layerTrigger.evaluate(e => document.activeElement === e), null)
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): 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')
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)
// 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 })
await page.getByRole('button', { name: '선스타일 외부 CENTER' }).click()
// 7. Color (26:12170 / 26:12184): RGB value | swatch, 192px dropdown left-aligned, ByLayer / ByBlock, chips, pick, validation.
const colorInput = field('color').locator('.hm-sw-property__color-value input')
check('Figma sample 255,259,0 shown as host text, swatch from host', await colorInput.inputValue() === '255,259,0', null)
await colorInput.click()
f = await style(field('color')); d = await style(drop.first())
const left = await style(field('color').locator('.hm-sw-property__color-value'), 'backgroundColor', 'borderRightColor')
check('Color On: dropdown 192 wide, left edges meet, RGB area #211D13 + divider', d.w === 192 && Math.abs(d.x - f.x) < 0.5 && Math.abs(d.y - (f.y + f.h)) < 0.5 && left.backgroundColor === 'rgb(33, 29, 19)' && left.borderRightColor === HOVER, { f, d, left })
check('Color dropdown height 158 (Figma)', d.h === 158, d.h)
await shot('on-color', page)
await drop.getByRole('button', { name: 'ByBlock' }).click()
check('ByBlock: callback, required single choice (ByBlock pressed, ByLayer not)', (await log()).includes('colorMode(byBlock)') && await drop.getByRole('button', { name: 'ByBlock' }).getAttribute('aria-pressed') === 'true' && await drop.getByRole('button', { name: 'ByLayer' }).getAttribute('aria-pressed') === 'false', null)
await drop.getByRole('button', { name: 'ByBlock' }).click()
check('Clicking the pressed mode is a no-op', (await log()).split('colorMode').length === 2, null)
await drop.getByRole('button', { name: '빨강' }).click()
check('Chip: property(color, 241,4,4)', (await log()).includes('property(color, 241,4,4)'), await log())
const inner = drop.getByRole('textbox', { name: '색상값 (R,G,B)' })
await inner.fill('255,259,0'); await page.keyboard.press('Enter')
check('Out-of-range 255,259,0 rejected (no callback, restored)', !(await log()).includes('255,259,0') && await inner.inputValue() === '241,4,4', await inner.inputValue())
await inner.fill('1, 2, 3'); await page.keyboard.press('Enter')
check('Valid RGB committed normalized (1,2,3)', (await log()).includes('property(color, 1,2,3)'), await log())
await drop.getByRole('button', { name: '색상 추출' }).click()
check('btn_colorize: host callback, dropdown closes', (await log()).includes('colorPick(color)') && await drop.count() === 0, null)
await colorInput.click(); await colorInput.fill('300,0,0'); await page.keyboard.press('Enter')
check('Field RGB 300,0,0 rejected', !(await log()).includes('300,0,0') && await colorInput.inputValue() === '1,2,3', await colorInput.inputValue())
await colorInput.fill('9,9,9'); await page.keyboard.press('Escape')
check('Escape #1 cancels draft, dropdown still open', await colorInput.inputValue() === '1,2,3' && await drop.count() === 1, null)
await page.keyboard.press('Escape')
check('Escape #2 closes the dropdown, panel stays', await drop.count() === 0 && await live.locator('.hm-sw-property').count() === 1, null)
// 8. Disabled / ReadOnly and external updates.
await page.getByRole('button', { name: '두께·레이어 ReadOnly' }).click()
await page.getByRole('button', { name: '선축적·색상 Disabled' }).click()
await row('thickness').locator('input').click()
const ro = await style(field('thickness'), 'backgroundColor')
await field('layer').locator('.hm-sw-property__trigger').click({ force: true })
check('ReadOnly 두께: no On; ReadOnly 레이어: no dropdown', ro.backgroundColor === CLEAR && await drop.count() === 0, ro)
await field('thickness').hover()
check('ReadOnly keeps Hover border', (await style(field('thickness'), 'borderTopColor')).borderTopColor === HOVER, null)
await colorInput.click({ force: true })
await field('scale').hover()
check('Disabled 색상: no dropdown; Disabled 선축적: no hover border', await drop.count() === 0 && (await style(field('scale'), 'borderTopColor')).borderTopColor === CLEAR, null)
await page.getByRole('button', { name: '두께·레이어 ReadOnly' }).click(); await page.getByRole('button', { name: '선축적·색상 Disabled' }).click()
await page.getByRole('button', { name: '선축적 외부 값 변경' }).click(); await page.getByRole('button', { name: '두께 단위 변경' }).click()
check('External value / unit props: 선축적 2.5000, 두께 cm', await scale.inputValue() === '2.5000' && (await row('thickness').locator('.hm-sw-property__unit').textContent()) === 'cm', null)
await page.getByRole('button', { name: '속성 추가' }).click(); await page.getByRole('button', { name: '하이퍼링크 삭제' }).click()
const ids = await live.locator('.hm-sw-property__row').evaluateAll(es => es.map(e => e.dataset.propertyId))
check('Dynamic rows: added at the end, removed row gone, order kept', ids.at(-1).startsWith('extra-') && !ids.includes('link') && ids[0] === 'layer-mixed', ids)
await page.getByRole('button', { name: /menus 없음/ }).click()
await field('line').locator('.hm-sw-property__trigger').click()
const fallback = await drop.locator('.hm-sw-header__line-column').count()
await page.keyboard.press('Escape')
check('Without menus: line types from options only (1 column), no crash', fallback === 1, fallback)
// 9. Pinned Hover specimen matches the real hover.
const pinned = await page.locator('[data-fields="hover"] .hm-sw-property__value[data-field]').first().evaluate(e => getComputedStyle(e).borderTopColor)
check('Pinned Hover specimen uses the same border', pinned === HOVER, pinned)
// 10. Four property panel states keep their Figma sizes.
await page.goto(`${base}/iframe.html?id=sw-header--property-panel-compare&viewMode=story`)
await page.locator('.hm-sw-property').first().waitFor(); await page.evaluate(() => document.fonts.ready)
const sizes = await page.locator('[data-compare]').evaluateAll(cells => cells.map(c => { const r = c.querySelector('.hm-sw-property').getBoundingClientRect(); return [c.dataset.compare, Math.round(r.width), Math.round(r.height)] }))
const want = { none: [314, 256], all: [314, 361], circle: [314, 658], expanded: [314, 754] }
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(`${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()
await page.locator('.hm-sw-property .hm-sw-property__row[data-property-id="layer"] .hm-sw-property__trigger').click()
const hosted = await page.evaluate(() => { const d = document.querySelector('.hm-sw-property__drop'), f = document.querySelector('.hm-sw-property__row[data-property-id="layer"] .hm-sw-property__value'); if (!d) return null; const a = d.getBoundingClientRect(), b = f.getBoundingClientRect(); return { right: a.right - b.right, top: a.top - b.bottom, panelOpen: !!document.querySelector('.hm-sw-property') } })
await page.keyboard.press('Escape')
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(); 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)