// 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 [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 }