Files
HMDesign/packages/sw/tests/viewer-logo-document-menu-firefox.browser.mjs
T
shkim4 2fc1b3e108 fix(sw): 2026-10-21 SW design review and responsive header without More from 760px
User-approved design review (2026-10-21):
- SW Header: from 760px up nothing moves into More; the five menus stay with their
  titles (titles and values ellipsize), file name shrinks to 96px. Open next to the
  logo no longer blocks field shrinking above 760px (760/768 overflow fixed, also for
  configured `controls` headers). Dropdown yellow ring inside the button, black seam.
- Property Bar values as read-only red text; property popup "=" labels, read-only
  black values, per-entry hover with pointer.
- Home Menu recent files (3 rows, path + name caption, empty preview), Footer + slot
  guide off by default, Viewer file list without icons, Storybook widths 1920-760.
- Tests: old More expectations (integrated-improvement, keyboard-dismiss,
  footer-popup-viewer-refinement, storybook-cleanup-stage1) moved to the new rule;
  functional checks kept.
2026-10-21 17:29:25 +09:00

221 lines
22 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.
// 2026-10-21 Firefox counterpart of viewer-logo-document-menu.browser.mjs: the installed Firefox over WebDriver BiDi (no
// Playwright build), real pointer clicks and key presses. Logo menu (SWHeaderMore) and file-name list
// (SWHeaderDocumentTitle) of SWViewerHeader: open / select / Escape / outside click / focus return / never together,
// title + divider rule, same face as the SWHeader `...` menu, responsive icon toggles and panels.
// Usage: node packages/sw/tests/viewer-logo-document-menu-firefox.browser.mjs <evidence-dir> [storybook-url]
import { mkdir, writeFile, rm } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import os from 'node:os'
import path from 'node:path'
import { runtimeRequire } from './playwright-runtime.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/viewer-logo-document-menu-qa/firefox')
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox/firefox.exe'
const VIEW = { width: 1920, height: 1100 }
const sleep = ms => new Promise(r => setTimeout(r, ms))
await mkdir(out, { recursive: true })
const profile = path.join(os.tmpdir(), `sw-ff-viewer-menu-${process.pid}`)
await mkdir(profile, { recursive: true })
const port = 9300 + (process.pid % 500)
const proc = spawn(FIREFOX, ['--headless', '--no-remote', '--profile', profile, `--remote-debugging-port=${port}`], { stdio: 'ignore', env: { ...process.env, MOZ_HEADLESS_WIDTH: String(VIEW.width), MOZ_HEADLESS_HEIGHT: String(VIEW.height) } })
let ws
for (let i = 0; i < 60 && !ws; i++) {
await sleep(500)
try { ws = await new Promise((res, rej) => { const s = new WebSocket(`ws://127.0.0.1:${port}/session`); s.onopen = () => res(s); s.onerror = rej }) } catch { ws = undefined }
}
if (!ws) throw new Error('Firefox BiDi not reachable')
let id = 0; const pending = new Map(), errors = []
ws.onmessage = event => {
const m = JSON.parse(event.data)
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.type === 'error' ? rej(new Error(`${m.error}: ${m.message}`)) : res(m.result) }
// Storybook's own UI font (sb-common-assets) is served by the dev server only, not by a static build; the Chromium
// suites ignore such 404s too.
else if (m.method === 'log.entryAdded' && m.params.level === 'error' && !/sb-common-assets/.test(m.params.text)) errors.push(m.params.text)
}
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej: e => rej(new Error(method + ' -> ' + e.message)) }); ws.send(JSON.stringify({ id: i, method, params })) })
const session = await send('session.new', { capabilities: {} })
await send('session.subscribe', { events: ['log.entryAdded'] })
// A tab of our own: in current Firefox the first tree entry can be a privileged (chrome) context that BiDi scripts
// and viewport calls may not touch without -remote-allow-system-access.
const context = (await send('browsingContext.create', { type: 'tab' })).context
const viewport = async size => { try { await send('browsingContext.setViewport', { context, viewport: size }) } catch (e) { console.log('INFO setViewport:', e.message) } }
await viewport(VIEW)
const ev = async expr => { const r = await send('script.evaluate', { expression: expr, target: { context }, awaitPromise: true }); if (r.type === 'exception') throw new Error(r.exceptionDetails.text + ' :: ' + expr.slice(0, 160)); return r.result.value }
const goto = async story => {
await send('browsingContext.navigate', { context, url: `${base}/iframe.html?id=${story}&viewMode=story`, wait: 'complete' })
for (let i = 0; i < 40; i++) { if (await ev(`!!document.querySelector('.hm-sw-viewer-header')`)) break; await sleep(250) }
// Lift the Story page envelope like the Chromium suites; scroll instantly (the preview sets smooth scrolling).
await ev(`(() => { const s = document.createElement('style'); s.textContent = '.sb-page { --sb-page-max-width: 100vw; } html { scroll-behavior: auto !important; }'; document.head.append(s); return true })()`)
await ev('document.fonts.ready.then(() => true)'); await sleep(400)
}
const frames = () => ev('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))')
const pointer = actions => send('input.performActions', { context, actions: [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions }] })
// Click the center of the first visible match (scrolled into view first), with a real pointer.
const click = async selector => {
const point = JSON.parse(await ev(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.getClientRects().length)
el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); const r = el.getBoundingClientRect(); return JSON.stringify([r.x + r.width / 2, r.y + r.height / 2]) })()`))
await pointer([{ type: 'pointerMove', x: Math.round(point[0]), y: Math.round(point[1]), duration: 0 }, { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }])
await frames()
}
const clickAt = async (x, y) => { await pointer([{ type: 'pointerMove', x, y, duration: 0 }, { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }]); await frames() }
const KEYS = { ArrowDown: '\uE015', Escape: '\uE00C', Enter: '\uE007' }
const press = async key => { await send('input.performActions', { context, actions: [{ type: 'key', id: 'kbd', actions: [{ type: 'keyDown', value: KEYS[key] }, { type: 'keyUp', value: KEYS[key] }] }] }); await frames() }
const focus = selector => ev(`(document.querySelector(${JSON.stringify(selector)}).focus(), true)`)
const shot = async (file, selector, width, height) => {
const clip = JSON.parse(await ev(`(() => { const r = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return JSON.stringify({ x: Math.max(0, r.x - 8), y: Math.max(0, r.y - 8) }) })()`))
const result = await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...clip, width, height } })
await writeFile(path.join(out, file), Buffer.from(result.data, 'base64'))
}
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : String(JSON.stringify(d) ?? '').slice(0, 600)) }
const state = () => ev(`(() => {
const header = document.querySelector('.hm-sw-viewer-header'), logo = header.querySelector('button.hm-sw-viewer-header__brand')
const menu = header.querySelector('.hm-sw-header__more-menu'), list = header.querySelector('.hm-sw-header__document-menu')
const name = header.querySelector('.hm-sw-viewer-header__document .hm-sw-header__document-button'), active = document.activeElement
return JSON.stringify({ logoExpanded: logo?.getAttribute('aria-expanded'), menu: !!menu, list: !!list, nameExpanded: name?.getAttribute('aria-expanded'),
name: name?.textContent.trim(), focus: active === logo ? 'logo' : active === name ? 'name' : (active?.textContent ?? '').trim(),
log: document.querySelector('[data-callback-log]')?.textContent ?? '' })
})()`).then(JSON.parse)
// Face of an open menu and its first shown row, split into colors and the other metrics (2026-10-21 style unification:
// the logo menu and the document list take the metrics of the viewer dropdown, SWViewerMenu, and keep their colors).
const FACE = `(menu, itemSelector) => { const s = getComputedStyle(menu), item = [...menu.querySelectorAll(itemSelector)].find(e => e.offsetParent), i = getComputedStyle(item)
const m = menu.getBoundingClientRect(), b = item.getBoundingClientRect()
return { colors: { background: s.backgroundColor, border: s.borderTopColor, shadow: s.boxShadow, item: i.color },
metrics: { border: s.borderTopWidth + ' ' + s.borderTopStyle, radius: s.borderTopLeftRadius, padding: s.paddingTop + ' ' + s.paddingLeft, minWidth: s.minWidth, font: s.fontSize + '/' + s.lineHeight + ' ' + s.fontWeight,
itemHeight: Math.round(b.height), itemInset: Math.round(b.left - m.left), itemPadding: i.paddingLeft + ' ' + i.paddingRight, itemGap: i.columnGap, itemRadius: i.borderTopLeftRadius,
itemFont: i.fontFamily.split(',')[0] + ' ' + i.fontSize + '/' + i.lineHeight + ' ' + i.fontWeight } } }`
const faceOf = (menuSelector, itemSelector) => ev(`JSON.stringify((${FACE})(document.querySelector(${JSON.stringify(menuSelector)}), ${JSON.stringify(itemSelector)}))`).then(JSON.parse)
// Scrolled into view first (the 520 row sits at the bottom of the Story; its list with Open reaches below the fold).
const onTop = selector => ev(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.scrollIntoView({ block: 'nearest', behavior: 'instant' }); const r = el.getBoundingClientRect(); return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height - 6)) })()`)
try {
console.log('firefox', (await ev('navigator.userAgent')))
// ---------- SWHeader `...` menu reference ----------
await goto('sw-viewer--compare-with-sw-header')
// 2026-10-21: SWHeader shows More only below 760px: the first header's 1440px Story wrapper is narrowed to 700px for
// this reference measurement (face colors and metrics do not depend on the width).
await ev(`(() => { document.querySelector('.hm-sw-header').parentElement.style.width = '700px'; return true })()`); await sleep(300)
await click('.hm-sw-header .hm-sw-header__more-trigger')
const reference = await faceOf('.hm-sw-header .hm-sw-header__more-menu', '.hm-sw-header__more-item')
const BOX = `element => { const s = getComputedStyle(element); return [s.borderTopWidth, s.borderTopStyle, s.borderTopColor, s.borderTopLeftRadius, s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor, s.boxShadow].join(' | ') }`
const refTrigger = await ev(`(${BOX})(document.querySelector('.hm-sw-header .hm-sw-header__more-trigger'))`)
check('SWHeader ... menu opens (reference colors)', !!reference.colors.background, reference)
await press('Escape')
check('SWHeader ... menu Escape closes, focus back on ...', await ev(`!document.querySelector('.hm-sw-header .hm-sw-header__more-menu') && document.activeElement?.classList.contains('hm-sw-header__more-trigger')`))
// ---------- SWViewerMenu reference metrics ("상단 메뉴 + Dropdown") ----------
await goto('sw-viewer--dropdowns')
await click('.hm-sw-viewer-header [aria-haspopup="menu"]')
const viewerMenu = await faceOf('.hm-sw-viewer-menu', '.hm-sw-viewer-menu__item')
check('SWViewerMenu reference opens (36px rows, 4px padding)', viewerMenu.metrics.itemHeight === 36 && viewerMenu.metrics.padding === '4px 4px', viewerMenu.metrics)
await press('Escape')
// ---------- File open button (the logo menu is gone) ----------
await goto('sw-viewer--viewer-header')
await click('.hm-sw-viewer-header .hm-sw-viewer-header__brand')
let s = await state()
check('logo opens no dropdown', !s.menu && !(await ev(`!!document.querySelector('[role=menu]')`)), s)
const open = JSON.parse(await ev(`(() => { const b = document.querySelector('.hm-sw-viewer-header [data-document-open]'), w = b.parentElement
return JSON.stringify({ surface: b.classList.contains('hm-sw-header__document-button') && w.matches('.hm-sw-header__document[data-tool]'), afterLogo: w.previousElementSibling?.classList.contains('hm-sw-viewer-header__brand'),
label: b.querySelector('.hm-sw-viewer-header__open-label')?.textContent, icon: b.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length }) })()`))
check('file open button after the logo: SWHeader document surface, icon + 파일 열기', open.surface && open.afterLogo && open.label === '파일 열기' && open.icon === 1, open)
const iconBox = await ev(`(${BOX})(document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__open-icon'))`)
// 2026-10-21 user request: no border, transparent, no shadow.
check('file open icon: no border, transparent, no shadow', iconBox.startsWith('0px | none') && iconBox.includes('rgba(0, 0, 0, 0)') && iconBox.endsWith('none'), iconBox)
await click('.hm-sw-viewer-header [data-document-open]')
s = await state()
check('file open button runs the existing file open', s.log.endsWith('파일 선택창 (미연결) → document/open'), s)
await shot('firefox-header-open.png', '.hm-sw-viewer-header .hm-sw-viewer-header__brand', 640, 60)
// ---------- Document list ----------
// The logo cap (and its slanted tail) has the same pixels with the list open, as in SWHeader. Left out: the bottom
// 2px, which the list's own drop shadow (0 2px 4px: blur 4 − offset 2) reaches above the list (Firefox 2px, Chromium 1px).
const capClip = JSON.parse(await ev(`(() => { const r = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__brand').getBoundingClientRect(); return JSON.stringify({ x: r.x, y: r.y, width: r.width + 18, height: r.height - 2 }) })()`))
const capShot = async () => (await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...capClip } })).data
const n = JSON.parse(await ev(`(() => { const r = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').getBoundingClientRect(); return JSON.stringify([r.x + r.width / 2, r.y + r.height / 2]) })()`))
await pointer([{ type: 'pointerMove', x: Math.round(n[0]), y: Math.round(n[1]), duration: 0 }]); await frames()
const capClosed = await capShot()
await click('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
check('open list never covers the logo cap (pixels identical)', capClosed === await capShot())
s = await state()
const list = JSON.parse(await ev(`(() => { const m = document.querySelector('.hm-sw-viewer-header .hm-sw-header__document-menu'), n = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
const r = m.getBoundingClientRect(), b = n.getBoundingClientRect(), row = m.querySelector('.hm-sw-header__document-row').getBoundingClientRect()
return JSON.stringify({ rows: [...m.querySelectorAll('.hm-sw-header__document-item-name')].map(e => e.textContent.trim()), actions: m.querySelectorAll('.hm-sw-header__document-footer, .hm-sw-header__document-action').length,
active: m.querySelector('[data-active=true] .hm-sw-header__document-item-name')?.textContent, width: r.width, button: b.width, left: r.left - b.left, right: r.right - b.right, row: Math.round(row.height), gap: Math.round(r.y - b.bottom), background: getComputedStyle(m).backgroundColor }) })()`))
check('file name click opens the 3 samples only (no Open / + New), SWHeader face', s.list && s.nameExpanded === 'true' && list.rows.length === 3 && list.actions === 0 && list.row === 32 && Math.abs(list.gap) <= 1
&& list.background === 'rgb(16, 49, 39)' && list.active === 'C0060202-001-평면및종단면도.dwg', list)
check('document list >= file-name button (outer) and >= 312px, covers it', list.width + 0.5 >= list.button && list.width >= 311.5 && list.left <= 0.5 && list.right >= -0.5, list)
check('list painted on top (not cut)', await onTop('.hm-sw-viewer-header .hm-sw-header__document-menu'))
await shot('firefox-document-list.png', '.hm-sw-viewer-header .hm-sw-viewer-header__brand', 640, 200)
await click('.hm-sw-viewer-header .hm-sw-header__document-row:last-child .hm-sw-header__document-item')
s = await state()
check('sample select loads it, renames, closes, focus on the file name', s.name === 'simple.dxf' && s.log.includes('samples/sample-dxf') && !s.list && s.focus === 'name', s)
await press('ArrowDown')
s = await state()
check('ArrowDown opens the list on its first row', s.list && s.focus === 'BasicSample.dwg', s)
await press('Escape')
s = await state()
check('Escape closes the list and refocuses the file name', !s.list && s.focus === 'name', s)
// ---------- Above open panels ----------
await goto('sw-theme--theme-inheritance')
const themedArea = '[data-theme-case="viewer-themed"] .hm-sw-viewer-header'
await click(`${themedArea} .hm-sw-viewer-header__document .hm-sw-header__document-button`)
check('document list above an open SWViewerPanel', await onTop(`${themedArea} .hm-sw-header__document-menu`))
await press('Escape')
// ---------- Responsive ----------
await goto('sw-viewer--responsive')
const report = JSON.parse(await ev(`JSON.stringify([...document.querySelectorAll('[data-width] .hm-sw-viewer-header')].map(h => { const rect = s => h.querySelector(s).getBoundingClientRect()
const toggles = [...h.querySelectorAll('.hm-sw-viewer-header__button[data-kind=toggle]')], object = h.querySelector('.hm-sw-viewer-header__object'), open = h.querySelector('[data-document-open]')
return { width: Math.round(h.getBoundingClientRect().width), overflow: h.scrollWidth - h.clientWidth, objectOverflow: object.scrollWidth - object.clientWidth,
toggles: toggles.map(t => [t.getAttribute('aria-label') ?? t.textContent.trim(), !!t.querySelector('.hm-sw-viewer-header__label'), !!t.title]),
fit: [...object.querySelectorAll('button')].some(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === '전체 보기'), more: !!h.querySelector('.hm-sw-viewer-header__more'),
open: [!!open.querySelector('.hm-sw-viewer-header__open-label'), open.title, open.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect().width],
order: [rect('.hm-sw-viewer-header__brand').right, open.getBoundingClientRect().left, open.getBoundingClientRect().right, rect('.hm-sw-viewer-header__document').left, rect('.hm-sw-viewer-header__document').right, rect('.hm-sw-viewer-header__object').left] } }))`))
check('no horizontal overflow at 1920 / 1440 / 1280 / 950 / 860 / 760 / 520', report.length === 7 && report.every(r => r.overflow <= 0 && r.objectOverflow <= 1), report.map(r => [r.width, r.overflow, r.objectOverflow]))
check('layer / settings / properties always in the header with tooltips', report.every(r => r.toggles.map(t => t[0]).join() === '레이어,설정,속성' && r.toggles.every(t => t[2])), report.map(r => [r.width, r.toggles]))
// 2026-10-21 measured steps (widths of SW Footer / SW Header Responsive More + 520): full at 1920 / 1440, icons at 950,
// More at 860 / 760 / 520. 1280 is at the measured full / icons edge (Firefox fits the labels, Chromium does not): either
// whole state, never More.
const full = r => r.fit && !r.more && r.open[0] && r.toggles.every(t => t[1])
const icons = r => !r.more && r.toggles.every(t => !t[1]) && !r.open[0] && r.open[1] === '파일 열기' && Math.round(r.open[2]) === 34
check('1920 / 1440 full (파일 열기 + 전체 보기 labels, no More); 1280 full or icons, no More; 950 icons only, no More; 860 / 760 / 520 icons + More', report.slice(0, 2).every(full)
&& (full(report[2]) || icons(report[2])) && icons(report[3]) && report.slice(4).every(r => r.more && r.toggles.every(t => !t[1]) && !r.open[0] && r.open[1] === '파일 열기' && Math.round(r.open[2]) === 34),
report.map(r => [r.width, r.fit, r.more, r.open, r.toggles.map(t => t[1])]))
check('logo, file open, file name and toolbar never overlap', report.every(r => r.order[0] <= r.order[1] + 2.5 && r.order[2] <= r.order[3] + 0.5 && r.order[4] <= r.order[5] + 0.5), report.map(r => [r.width, r.order]))
await click('[data-width="520"] .hm-sw-viewer-header__document .hm-sw-header__document-button')
check('520: document list shown inside the header width', await onTop('[data-width="520"] .hm-sw-header__document-menu') && await ev(`(() => { const m = document.querySelector('[data-width="520"] .hm-sw-header__document-menu').getBoundingClientRect(), h = document.querySelector('[data-width="520"] .hm-sw-viewer-header').getBoundingClientRect(); return m.x >= h.x - 2 && m.right <= h.right })()`))
await press('Escape')
await click('[data-width="520"] [data-document-open]')
check('520: icon-only file open runs the file open', (await state()).log !== undefined && await ev(`document.querySelector('[data-callback-log]').textContent.endsWith('document/open')`))
// ---------- Narrow screen: icon toggles open the panels ----------
await viewport({ width: 820, height: 900 })
await goto('sw-viewer--viewer-header')
const panels = []
for (const [name, dialog] of [['레이어', '레이어 ('], ['설정', '옵션'], ['속성', '속성']]) {
const selector = `.hm-sw-viewer-header .hm-sw-viewer-header__button[aria-label="${name}"]`
await click(selector)
const after = JSON.parse(await ev(`(() => { const t = document.querySelector(${JSON.stringify(selector)}); return JSON.stringify({ pressed: t.getAttribute('aria-pressed'), color: getComputedStyle(t).color, title: t.title,
open: [...document.querySelectorAll('[role=dialog]')].some(d => (d.getAttribute('aria-label') ?? d.textContent).includes(${JSON.stringify(dialog)}) || document.getElementById(d.getAttribute('aria-labelledby'))?.textContent.includes(${JSON.stringify(dialog)})) }) })()`))
await click(selector)
after.closed = await ev(`document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-pressed') === 'false'`)
panels.push({ name, ...after })
}
const width = await ev(`Math.round(document.querySelector('.hm-sw-viewer-header').getBoundingClientRect().width)`)
check(`narrow header (${width}px): icon toggles open / close panels, pressed #FFC600, tooltip`, width < 900 && panels.every(p => p.pressed === 'true' && p.color === 'rgb(255, 198, 0)' && p.title && p.open && p.closed), panels)
await shot('firefox-viewer-narrow.png', '.hm-sw-viewer-header', 820, 60)
} finally {
check('no console errors', errors.length === 0, errors.slice(0, 5))
const failed = checks.filter(c => !c.ok).length
await writeFile(path.join(out, 'viewer-logo-document-menu-firefox.json'), JSON.stringify({ base, checks, errors }, null, 2))
console.log(`${checks.length - failed}/${checks.length} PASS (firefox)`)
try { await send('session.end', {}) } catch {}
ws.close(); proc.kill()
await sleep(500); await rm(profile, { recursive: true, force: true }).catch(() => {})
process.exitCode = failed ? 1 : 0
}