// SW viewer mode verification (2026-10-19). // 1) Captures the reference DWG/DXF viewer and the SW/Viewer Stories at the same resolution (1440 x 900). // 2) Runs interaction checks on the Stories (menus, keyboard, Escape, outside click, panels, More, modal). // Usage: node packages/sw/tests/viewer.browser.mjs // e.g. node packages/sw/tests/viewer.browser.mjs http://127.0.0.1:6034 http://127.0.0.1:5182 docs/sw/viewer-qa import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { runtimeRequire } from './playwright-runtime.mjs' const require = runtimeRequire() const { chromium } = require('playwright') const [storybook, reference, outArg] = process.argv.slice(2) const out = path.resolve(outArg ?? 'docs/sw/viewer-qa') await mkdir(out, { recursive: true }) const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' }) const results = [] const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) } const errors = [] const story = id => `${storybook}/iframe.html?id=sw-viewer--${id}&viewMode=story${id === 'dropdowns' ? '&verification' : ''}` const newPage = async (width = 1440, height = 900) => { const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' }) page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`)) page.on('console', message => { if (message.type() === 'error' && !/favicon|404/.test(message.text())) errors.push(`${page.url()}: ${message.text()}`) }) return page } // Test-only: lift the 1280px Story page envelope so the 1440px frames are measured at full width. const open = async (page, id) => { await page.goto(story(id)); await page.waitForSelector('.hm-sw-viewer-header'); await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' }); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(200) } const log = page => page.locator('[data-callback-log]').first().innerText() const box = locator => locator.boundingBox() // ---------- 1. Same-resolution captures ---------- const geometry = async (page, selectors) => page.evaluate(selectors => { const header = document.querySelector(selectors.header) const base = header.getBoundingClientRect() return Object.fromEntries(Object.entries(selectors.parts).map(([name, selector]) => [name, [...(selectors.scoped ? header : document).querySelectorAll(selector)].map(element => { const rect = element.getBoundingClientRect() return { x: Math.round(rect.x - base.x), y: Math.round(rect.y - base.y), w: Math.round(rect.width), h: Math.round(rect.height), text: element.textContent.trim().slice(0, 12) } })])) }, selectors) const parts = { reference: { header: 'header', parts: { document: '.cad-toolbar-document', object: '.cad-toolbar-object-area', field: '.cad-toolbar-property-field', button: '.cad-toolbar [data-slot=button]', toggle: '.cad-toolbar [data-slot=toggle]', tab: '.cad-toolbar [data-slot=tabs-trigger]', switchField: '.cad-toolbar-switch-field' } }, package: { header: '.hm-sw-viewer-header', scoped: true, parts: { document: '.hm-sw-viewer-header__document', object: '.hm-sw-viewer-header__object', field: '.hm-sw-viewer-header__group[data-surface=field]', button: '.hm-sw-viewer-header__button:not([data-kind=toggle])', toggle: '.hm-sw-viewer-header__button[data-kind=toggle]', tab: '.hm-sw-viewer-header__tab', switchField: '.hm-sw-viewer-header__switch-field' } }, } const comparison = {} if (reference && reference !== '-') { const page = await newPage() await page.goto(reference) await page.waitForSelector('header') await page.evaluate(() => document.fonts.ready) await page.screenshot({ path: path.join(out, 'reference-1440-idle.png'), clip: { x: 0, y: 0, width: 1440, height: 56 } }) await page.getByRole('button', { name: '샘플' }).click() await page.screenshot({ path: path.join(out, 'reference-1440-sample-menu.png'), clip: { x: 0, y: 0, width: 1440, height: 200 } }) await page.getByRole('menuitem', { name: /평면 및 종단면도/ }).click() await page.waitForFunction(() => document.querySelector('.cad-toolbar-filename')?.textContent?.includes('C0060202'), null, { timeout: 30000 }) await page.waitForTimeout(1500) comparison.reference = await geometry(page, parts.reference) await page.screenshot({ path: path.join(out, 'reference-1440-loaded.png'), clip: { x: 0, y: 0, width: 1440, height: 56 } }) await page.getByRole('button', { name: '레이어 패널' }).click() await page.getByRole('button', { name: '옵션 패널' }).click() await page.waitForTimeout(300) await page.screenshot({ path: path.join(out, 'reference-1440-panels.png') }) await page.close() } { const page = await newPage(1920, 1000) await open(page, 'viewer-header') // 2026-10-21: the Story also shows the minimize / close version below; the checks use the first (basic) frame. const frame = page.locator('[data-viewer-frame]').first() const origin = await box(frame) comparison.package = await geometry(page, parts.package) await page.screenshot({ path: path.join(out, 'package-1440-loaded.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 56 } }) // 2026-10-21: the samples are the file-name list (SWHeaderDocumentTitle), see viewer-logo-document-menu.browser.mjs. await frame.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').click() await page.screenshot({ path: path.join(out, 'package-1440-sample-menu.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 200 } }) await page.keyboard.press('Escape') await frame.getByRole('button', { name: '레이어' }).click() await frame.getByRole('button', { name: '설정' }).click() await page.waitForTimeout(200) await page.screenshot({ path: path.join(out, 'package-1440-panels.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 620 } }) await page.close() } if (comparison.reference) { // Same item = same size (positions shift with the file-name text width, which differs by sample data). for (const key of ['button', 'toggle', 'tab', 'switchField', 'field']) { const a = comparison.reference[key].map(item => `${item.w}x${item.h}`), b = comparison.package[key].map(item => `${item.w}x${item.h}`) // field: the reference Tabs root is 39px inside its 40px frame, so its tab field is 41px; the package keeps both fields 42px (documented 1px difference). const within = key === 'field' ? comparison.reference[key].every((item, index) => comparison.package[key][index] && item.w === comparison.package[key][index].w && Math.abs(item.h - comparison.package[key][index].h) <= 1) : JSON.stringify(a) === JSON.stringify(b) check(`geometry ${key} sizes equal to reference${key === 'field' ? ' (±1px height, known)' : ''}`, within, `reference ${a.join(' ')} / package ${b.join(' ')}`) } const [r, p] = [comparison.reference.document[0], comparison.package.document[0]] // x differs by design: the reference always draws "HM" (72px cap); the package draws no logo unless given (24px cap). check('geometry document well height / top (x: logo rule)', r.h === p.h && r.y === p.y && p.x === r.x - 48, `reference x${r.x} y${r.y} h${r.h} / package x${p.x} y${p.y} h${p.h}`) } // ---------- 2. Interactions ---------- { // Wide enough for the 1440px story frame plus the story page padding (panels at the frame right edge stay on screen). const page = await newPage(1920, 1000) await open(page, 'viewer-header') // 2026-10-21: the Story also shows the minimize / close version below; these checks use the first (basic) frame. const root = page.locator('[data-viewer-frame]').first() // Samples (2026-10-21): the file-name list (SWHeaderDocumentTitle) replaced the 샘플 menu button. Click opens, rows listed, // Escape closes and returns focus; the logo menu (Open) is checked in viewer-logo-document-menu.browser.mjs. const samples = page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').first() await samples.click() const menu = root.getByRole('group', { name: '문서 목록' }) check('document list opens on click', await menu.isVisible()) check('document list has the 3 samples', (await menu.locator('.hm-sw-header__document-item').count()) === 3) const menuBox = await box(menu), triggerBox = await box(samples) check('list placed under the file name', menuBox.y >= triggerBox.y + triggerBox.height - 1, `list ${Math.round(menuBox.x)},${Math.round(menuBox.y)} name ${Math.round(triggerBox.x)},${Math.round(triggerBox.y + triggerBox.height)}`) check('trigger shows open state', (await samples.getAttribute('aria-expanded')) === 'true' && (await samples.getAttribute('data-selected')) === 'true') await page.keyboard.press('ArrowDown') await page.keyboard.press('ArrowDown') check('ArrowDown moves focus in the list', (await page.evaluate(() => document.activeElement?.textContent)).includes('평면')) await page.keyboard.press('Escape') check('Escape closes the list', !(await menu.isVisible().catch(() => false))) check('Escape returns focus to the file name', await samples.evaluate(element => element === document.activeElement)) // Keyboard open + Enter select → file name changes, selection logged by the host. await samples.press('ArrowDown') check('ArrowDown opens with the first row focused', (await page.evaluate(() => document.activeElement?.classList.contains('hm-sw-header__document-item')))) // 2026-10-21: no Open below the rows (file open is the header button); ArrowUp wraps to the last sample. await page.keyboard.press('ArrowUp') await page.keyboard.press('Enter') check('Enter selects a sample (document changes)', (await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-name').first().innerText()) === 'simple.dxf') check('onDocumentSelect reported', (await log(page)).includes('samples/sample-dxf'), await log(page)) // Outside click closes. await samples.click() await page.mouse.click(700, 600) check('outside click closes the list', (await root.getByRole('group', { name: '문서 목록' }).count()) === 0) // Tabs: arrow keys select. const model = root.getByRole('radio', { name: /Model/ }) await model.focus() await page.keyboard.press('ArrowRight') check('tabs ArrowRight selects Layout', (await root.getByRole('radio', { name: /Layout/ }).getAttribute('aria-checked')) === 'true') // Switch const depth = root.getByRole('switch', { name: '2.5D' }) await depth.click() check('header switch toggles', (await depth.getAttribute('aria-checked')) === 'true' && (await log(page)).includes('2.5d=true')) // Hover face (2026-10-21: 전체 보기 left the header row for More, so the panel toggle is hovered). const hovered = root.getByRole('button', { name: '레이어', exact: true }) await hovered.hover() check('hover face #266651', (await hovered.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)') // Toggle opens the panel; Escape inside closes it and focus goes back. const layers = root.getByRole('button', { name: '레이어', exact: true }) await layers.click() const layerPanel = root.getByRole('dialog', { name: /레이어 \(/ }) check('toggle opens panel', await layerPanel.isVisible()) check('toggle pressed state + #FFC600', (await layers.getAttribute('aria-pressed')) === 'true' && (await layers.evaluate(element => getComputedStyle(element).color)) === 'rgb(255, 198, 0)') check('panel focused on open', await layerPanel.evaluate(element => element.contains(document.activeElement))) await page.keyboard.press('Escape') check('Escape closes panel', (await root.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0) check('focus returns to toggle', await layers.evaluate(element => element === document.activeElement)) // Resize by the native corner (right after opening, top-end placement). await layers.click() const sized = await box(layerPanel) await page.mouse.move(sized.x + sized.width - 6, sized.y + sized.height - 6) await page.mouse.down() await page.mouse.move(sized.x + sized.width - 63, sized.y + sized.height - 103, { steps: 6 }) await page.mouse.up() const resized = await box(layerPanel) check("corner resize changes size", resized.width < sized.width - 40 && resized.height < sized.height - 80, `${Math.round(sized.width)}x${Math.round(sized.height)} → ${Math.round(resized.width)}x${Math.round(resized.height)}`) // Drag: title bar moves the panel and it stays inside the frame. const before = await box(layerPanel) const title = layerPanel.locator('.hm-sw-property__titlebar') const titleBox = await box(title) await page.mouse.move(titleBox.x + 40, titleBox.y + 15) await page.mouse.down() await page.mouse.move(titleBox.x - 260, titleBox.y + 45, { steps: 8 }) await page.mouse.up() const after = await box(layerPanel) check('drag moves panel', Math.round(before.x - after.x) === 300 && Math.round(after.y - before.y) === 30, `dx ${Math.round(after.x - before.x)} dy ${Math.round(after.y - before.y)}`) await page.mouse.move(after.x + 40, after.y + 15) await page.mouse.down() await page.mouse.move(after.x + 3000, after.y + 3000, { steps: 6 }) await page.mouse.up() const frame = await box(root.locator('.sw-viewer-story-canvas')) const clamped = await box(layerPanel) check('drag stays inside bounds (10px edge)', Math.round(frame.x + frame.width - (clamped.x + clamped.width)) === 10 && Math.round(frame.y + frame.height - (clamped.y + clamped.height)) === 10, `right gap ${Math.round(frame.x + frame.width - (clamped.x + clamped.width))}, bottom gap ${Math.round(frame.y + frame.height - (clamped.y + clamped.height))}`) // Collapse by button and double click. await layerPanel.getByRole('button', { name: '접기' }).click() check('collapse hides body', !(await layerPanel.locator('.hm-sw-property__body').isVisible())) await title.dblclick({ position: { x: 30, y: 15 } }) check('title double click expands', await layerPanel.locator('.hm-sw-property__body').isVisible()) // Panel content works (layer eye). // 전체 켜기 / 전체 끄기: green on hover and while pressed, default otherwise; other panel buttons unchanged. const allOn = layerPanel.getByRole('button', { name: '전체 켜기' }) const face = locator => locator.evaluate(element => [getComputedStyle(element).backgroundColor, getComputedStyle(element).color].join(' ')) const restFace = await face(allOn) await allOn.hover() const hoverFace = await face(allOn) await page.mouse.down() const activeFace = await face(allOn) await page.mouse.up() const afterFace = await face(allOn) await page.mouse.move(5, 5) const leftFace = await face(allOn) check('전체 켜기: default kept, green hover / active, back after release', restFace === 'rgb(255, 255, 255) rgb(18, 51, 40)' && hoverFace === 'rgb(38, 102, 81) rgb(255, 255, 255)' && activeFace === 'rgb(18, 51, 40) rgb(255, 255, 255)' && afterFace === hoverFace && leftFace === restFace, [restFace, hoverFace, activeFace, afterFace, leftFace].join(' | ')) await layerPanel.getByRole('button', { name: 'CR-TEXT 숨기기' }).click() check('panel content callback', (await log(page)).includes('layer:CR-TEXT=false')) // Close button hover face. const close = layerPanel.getByRole('button', { name: '닫기' }) await close.hover() check('close hover = SWPopup hover asset', await close.evaluate(element => getComputedStyle(element.querySelector('.hm-sw-property__close-hover')).visibility === 'visible' && getComputedStyle(element.querySelector('.hm-sw-property__close-default')).visibility === 'hidden')) await close.click() check('close button closes', (await root.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0) // Two panels: the last touched comes to the front. await layers.click() await root.getByRole('button', { name: '설정', exact: true }).click() const options = root.getByRole('dialog', { name: '옵션' }) const z = async () => Promise.all([layerPanel, options].map(item => item.evaluate(element => Number(getComputedStyle(element).zIndex)))) await layerPanel.locator('.hm-sw-property__titlebar').click({ position: { x: 30, y: 15 } }) const [zl, zo] = await z() check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`) // Modal popup: backdrop, focus trap, Escape. await root.getByRole('button', { name: '정보 및 라이선스' }).click() const about = root.getByRole('dialog', { name: /라이선스/ }) const confirm = about.getByRole('button', { name: '확인' }) await confirm.hover() const confirmHover = await face(confirm) check('전체 켜기 hover = 라이선스 확인 hover (same token)', confirmHover === hoverFace, `${confirmHover} / ${hoverFace}`) await about.locator('.hm-sw-property__title').hover() check('modal popup opens with backdrop', (await about.getAttribute('aria-modal')) === 'true' && (await page.locator('.hm-sw-viewer-panel-backdrop').count()) === 1) for (let index = 0; index < 4; index++) await page.keyboard.press('Tab') check('modal focus stays inside', await about.evaluate(element => element.contains(document.activeElement))) await page.keyboard.press('Escape') check('modal Escape closes', (await root.getByRole('dialog', { name: /라이선스/ }).count()) === 0) await page.close() } { // Submenus and the context menu. const page = await newPage(1920, 1100) await open(page, 'dropdowns') await page.getByRole('button', { name: '도면' }).press('Enter') const recent = page.getByRole('menuitem', { name: '최근 도면' }) await recent.focus() await page.keyboard.press('ArrowRight') const sub = page.getByRole('menu', { name: '최근 도면' }) check('ArrowRight opens submenu with focus', (await sub.isVisible()) && await sub.evaluate(element => element.contains(document.activeElement))) const parentBox = await box(page.getByRole('menu', { name: '도면', exact: true })), subBox = await box(sub) check('submenu opens to the side', subBox.x >= parentBox.x + parentBox.width, `parent right ${Math.round(parentBox.x + parentBox.width)}, sub left ${Math.round(subBox.x)}`) await page.getByRole('menuitem', { name: '보관 도면' }).hover() check('third level opens on hover', await page.getByRole('menu', { name: '보관 도면' }).isVisible()) await page.getByRole('menuitem', { name: '보관 도면' }).focus() await page.keyboard.press('ArrowLeft') check('ArrowLeft closes one level', (await page.getByRole('menu', { name: '최근 도면' }).count()) === 0 && (await page.getByRole('menu', { name: '도면', exact: true }).count()) === 1) await page.getByRole('menuitemcheckbox', { name: '선가중치 표시' }).click() check('checkbox entry toggles via onCommand', (await log(page)).includes('file/lineweight=true'), await log(page)) await page.getByRole('button', { name: '도면' }).click() check('checkbox state reflected', (await page.getByRole('menuitemcheckbox', { name: '선가중치 표시' }).getAttribute('aria-checked')) === 'true') await page.getByRole('menuitem', { name: '인쇄 (미연결)' }).click({ force: true }) check('disabled entry does nothing', (await page.getByRole('menu', { name: '도면', exact: true }).count()) === 1) await page.keyboard.press('Escape') const right = page.getByRole('button', { name: '오른쪽 정렬 메뉴' }) await right.click() const rightMenu = page.getByRole('menu', { name: '오른쪽 정렬 메뉴' }) const [rb, rm] = [await box(right), await box(rightMenu)] check('menuAlign end + menuWidth', Math.abs(rb.x + rb.width - (rm.x + rm.width)) <= 1 && Math.round(rm.width) === 220, `width ${Math.round(rm.width)}`) await page.keyboard.press('Escape') const area = page.locator('[data-context-area]') const areaBox = await box(area) await page.mouse.click(areaBox.x + 50, areaBox.y + 40, { button: 'right' }) const context = page.getByRole('menu', { name: '도면 메뉴' }) const contextBox = await box(context) check('context menu opens at pointer', Math.abs(contextBox.x - (areaBox.x + 50)) <= 1 && Math.abs(contextBox.y - (areaBox.y + 40)) <= 1, `${Math.round(contextBox.x)},${Math.round(contextBox.y)}`) await page.getByRole('menuitem', { name: '선택 확대' }).click() check('context entry reported', (await log(page)).includes('context/zoom-selection')) // Menu kept inside the viewport near the right edge. await page.setViewportSize({ width: 900, height: 700 }) await page.mouse.click(0, 0) await page.evaluate(() => window.scrollTo(0, 0)) await page.waitForTimeout(100) await page.locator('.sw-story-scroller').first().evaluate(element => { element.scrollLeft = element.scrollWidth }) await right.click() const edge = await box(page.getByRole('menu', { name: '오른쪽 정렬 메뉴' })) check('menu stays inside the viewport', edge.x >= 4 && edge.x + edge.width <= 900 - 4, `left ${Math.round(edge.x)}, right ${Math.round(edge.x + edge.width)}`) await page.close() } { // Responsive: measured steps (full → icons only → More), no horizontal overflow; More collects the collapsed items. const page = await newPage(1920, 1200) await open(page, 'responsive') const headers = page.locator('[data-width] .hm-sw-viewer-header') const report = [] for (const [index, width] of [1440, 1100, 860, 520].entries()) { const header = headers.nth(index) const info = await header.evaluate(element => ({ overflow: element.scrollWidth - element.clientWidth, objectOverflow: (() => { const object = element.querySelector('.hm-sw-viewer-header__object'); return object.scrollWidth - object.clientWidth })(), more: !!element.querySelector('.hm-sw-viewer-header__more'), labels: [...element.querySelectorAll('.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__switch-label')].map(item => item.textContent.trim() || item.getAttribute('aria-label')), })) report.push({ width, ...info }) const box2 = await box(header) await page.screenshot({ path: path.join(out, `package-responsive-${width}.png`), clip: { x: box2.x, y: box2.y, width: box2.width, height: box2.height } }) } check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), JSON.stringify(report.map(item => [item.width, item.overflow, item.objectOverflow]))) // 2026-10-21: 파일 열기 / 샘플 left the header (logo menu, file-name list). Measured steps instead of fixed widths: // full items while they fit, then icons only, then More; the panel toggles never move into More. With this Story's // content: 1440 / 1100 full, 860 icons only, 520 icons + More. // 2026-10-21: with the file open button after the logo, 1100 fits as icons (still no More) and 860 needs More. check('1440 / 1100: every item in the header (전체 보기 included), no More', [report[0], report[1]].every(item => !item.more && ['전체 보기', '2.5D', '어두운 배경', '레이어'].every(name => item.labels.includes(name)))) check('860: icons only + More', report[2].more && !(await headers.nth(2).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count())) check('520: icons only + More; panel toggles kept as icons (label → aria-label)', report[3].more && ['레이어', '설정', '속성'].every(name => report[3].labels.includes(name)) && !(await headers.nth(3).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count())) check('no 파일 열기 / 샘플 header buttons at any width', report.every(item => !item.labels.includes('파일 열기') && !item.labels.includes('샘플'))) const more = headers.nth(3).getByRole('button', { name: '추가 도구' }) await more.click() const moreMenu = page.getByRole('menu', { name: '추가 도구' }) const items = await moreMenu.locator('.hm-sw-viewer-menu__label, [role^=menuitem]').allInnerTexts() check('More sections: 도면 공간, 화면 보기 (with 전체 보기); no 패널', ['도면 공간', '화면 보기', '전체 보기'].every(label => items.includes(label)) && !items.includes('패널'), items.join(' | ')) // 2026-10-21: the header dropdowns (More included) take the SWHeader `...` menu face (#0e3d2f); width unchanged. check('More face (SWHeader ... menu #0e3d2f) 260px', (await moreMenu.evaluate(element => `${getComputedStyle(element).backgroundColor} ${element.offsetWidth}`)) === 'rgb(14, 61, 47) 260') await moreMenu.getByRole('menuitemcheckbox', { name: '2.5D' }).click() check('More checkbox reports command', (await log(page)).includes('2.5d=true'), await log(page)) await more.click() await page.getByRole('menuitemradio', { name: /Layout/ }).click() check('More radio selects tab value', (await log(page)).includes('space=layout1')) await page.screenshot({ path: path.join(out, 'package-responsive-more-open.png') }) await page.close() } { // Story captures (full page) for the report. const page = await newPage(1920, 1000) for (const id of ['viewer-header', 'menu-groups', 'states', 'dropdowns', 'settings-panel', 'content-variants', 'variable-size', 'responsive', 'logo', 'compare-with-sw-header']) { await open(page, id) await page.screenshot({ path: path.join(out, `story-${id}.png`), fullPage: true }) } // Mode switch keeps state. await open(page, 'compare-with-sw-header') await page.getByLabel('뷰어 모드 (SWViewerHeader)').check() const frame = page.locator('[data-viewer-frame]').last() check('mode switch shows viewer header', (await frame.locator('.hm-sw-viewer-header').count()) === 1 && (await frame.locator('.hm-sw-header').count()) === 0) await frame.getByRole('button', { name: '레이어', exact: true }).click() await frame.getByRole('button', { name: '임시 로고 (일반 모드로) 홈' }).click() check('brand click returns to normal SWHeader', (await frame.locator('.hm-sw-header').count()) === 1 && (await frame.locator('.hm-sw-viewer-header').count()) === 0) await page.getByLabel('뷰어 모드 (SWViewerHeader)').check() check('state kept across mode switch (layer panel still open)', (await frame.getByRole('dialog', { name: /레이어 \(/ }).count()) === 1) await page.close() } { // Logo: nothing drawn without one, image / element when given; layout unchanged. const page = await newPage(1920, 1000) await open(page, 'logo') const cases = page.locator('[data-logo-case]') const read = index => cases.nth(index).evaluate(element => { const brand = element.querySelector('.hm-sw-viewer-header__brand'), doc = element.querySelector('.hm-sw-viewer-header__document') const base = element.getBoundingClientRect() return { text: brand.textContent.trim(), img: !!brand.querySelector('img, .hm-sw-viewer-header__brand-mark svg'), width: Math.round(brand.getBoundingClientRect().width), docX: Math.round(doc.getBoundingClientRect().x - base.x), height: Math.round(element.querySelector('.hm-sw-viewer-header').getBoundingClientRect().height), fileOpenY: Math.round(element.querySelector('.hm-sw-viewer-header__button').getBoundingClientRect().y - base.y), } }) const [none, src, node] = [await read(0), await read(1), await read(2)] check('logo none: no HM / text / icon', none.text === '' && !none.img, JSON.stringify(none)) check('logo none: cap shrinks to its edge (no wide gap)', none.width === 24, JSON.stringify(none)) const logoWidth = await cases.nth(1).locator('.hm-sw-viewer-header__brand-mark').evaluate(element => Math.round(element.getBoundingClientRect().width)) check('logoSrc shows the image (cap = 24 + logo + 25, min 72)', src.img && src.width === Math.max(72, 24 + logoWidth + 25), JSON.stringify({ ...src, logoWidth })) check('logo element shown', node.img && node.width >= 72, JSON.stringify(node)) check('header height and menu row unchanged with / without logo', [none, src, node].every(item => item.height === 52 && item.fileOpenY === none.fileOpenY), [none, src, node].map(item => item.height + '/' + item.fileOpenY).join(' ')) for (const [index, name] of [[0, 'none'], [1, 'src'], [2, 'element']]) { const b = await box(cases.nth(index)) await page.screenshot({ path: path.join(out, 'package-logo-' + name + '.png'), clip: { x: b.x, y: b.y, width: 520, height: 52 } }) } // Text / icon outline: same tokens as SWHeader, fill unchanged. const outline = await page.evaluate(() => { const header = document.querySelector('[data-logo-case] .hm-sw-viewer-header') const style = selector => getComputedStyle(header.querySelector(selector)) return { // 2026-10-21: the file name is the SWHeader document button when `documents` is given (same outline token). label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name, .hm-sw-header__document-name').textShadow, tab: style('.hm-sw-viewer-header__tab').textShadow, caption: style('.hm-sw-viewer-header__switch-label').textShadow, count: style('.hm-sw-viewer-header__count').textShadow, icon: (() => { // A lucide (stroke) icon: since 2026-10-21 the first header icon is the More dots (fill), so take the 레이어 toggle. const icon = header.querySelector('.hm-sw-viewer-header__button[data-kind="toggle"] .hm-sw-viewer-header__icon') const copy = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__outline svg *')), glyph = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__glyph svg *')) // lucide 24-unit icon drawn at 16px: 1px each side = 1.5 units, so the copy is 2 + 3 units. return { filter: getComputedStyle(icon).filter, copyStroke: copy.stroke, copyWidth: parseFloat(copy.strokeWidth), glyphStroke: glyph.stroke, glyphWidth: parseFloat(glyph.strokeWidth) } })(), labelColor: style('.hm-sw-viewer-header__label').color, labelFont: [style('.hm-sw-viewer-header__label').fontSize, style('.hm-sw-viewer-header__label').fontWeight, style('.hm-sw-viewer-header__label').letterSpacing].join(' '), } }) const blacks = value => value.split('rgb(0, 0, 0)').length - 1 check('text outline 1.3px black on 14px text (label / file name / tab)', [outline.label, outline.document, outline.tab].every(value => value.includes('rgb(0, 0, 0) 1.3px 0px 0px') && blacks(value) === 16), outline.label.slice(0, 60)) check('label outline 1px black on 11px captions / counts', [outline.caption, outline.count].every(value => value.includes('rgb(0, 0, 0) 1px 0px 0px') && blacks(value) === 16), outline.caption.slice(0, 60)) check('icon outline: SVG stroke copy, no CSS filter', outline.icon.filter === 'none' && outline.icon.copyStroke === 'rgb(0, 0, 0)' && outline.icon.copyWidth === outline.icon.glyphWidth + 3 && outline.icon.glyphStroke !== 'rgb(0, 0, 0)', JSON.stringify(outline.icon)) // 2026-10-20 user request: header labels (파일열기 · 샘플 · 전체보기 · 레이어 · 설정 · 속성), file name and Model / Layout are Bold (700). check('fill / size / weight / spacing kept (labels Bold 700 since 2026-10-20)', outline.labelColor === 'rgb(255, 255, 255)' && outline.labelFont === '14px 700 normal', outline.labelColor + ' ' + outline.labelFont) await page.close() } { // SWHeader vs SWViewerHeader text / icon outline at 3x for the report. const page = await browser.newPage({ viewport: { width: 1920, height: 1000 }, deviceScaleFactor: 3 }) await open(page, 'compare-with-sw-header') const sw = await box(page.locator('.hm-sw-header').first()), vw = await box(page.locator('.hm-sw-viewer-header').first()) await page.screenshot({ path: path.join(out, 'outline-swheader-3x.png'), clip: { x: sw.x, y: sw.y, width: 760, height: sw.height } }) await page.screenshot({ path: path.join(out, 'outline-viewer-3x.png'), clip: { x: vw.x, y: vw.y, width: 760, height: vw.height } }) await page.screenshot({ path: path.join(out, 'outline-viewer-right-3x.png'), clip: { x: vw.x + 1000, y: vw.y, width: 440, height: vw.height } }) const hovered = page.locator('.hm-sw-viewer-header').first().getByRole('button', { name: '레이어', exact: true }) await hovered.hover() check('hover changes fill only (outline kept)', (await hovered.locator('.hm-sw-viewer-header__label').evaluate(element => getComputedStyle(element).textShadow)).includes('1.3px')) await page.close() const states = await browser.newPage({ viewport: { width: 1920, height: 900 }, deviceScaleFactor: 2 }) await open(states, 'states') const header = await box(states.locator('.hm-sw-viewer-header')) await states.getByRole('button', { name: '기본' }).hover() await states.screenshot({ path: path.join(out, 'outline-states-2x.png'), clip: { x: header.x, y: header.y, width: 1440, height: header.height } }) const disabled = await states.getByRole('button', { name: 'Disabled', exact: true }).evaluate(element => [getComputedStyle(element).opacity, getComputedStyle(element.querySelector('.hm-sw-viewer-header__label')).textShadow.includes('1.3px')]) check('disabled fades text + outline together', disabled[0] === '0.6' && disabled[1] === true, JSON.stringify(disabled)) await states.close() } { // Storybook sidebar: SW/Tool removed; SW/Popup (common SWPopup examples), SW/Header and SW/Viewer remain. const index = await (await fetch(storybook + '/index.json')).json() const titles = [...new Set(Object.values(index.entries).map(entry => entry.title).filter(title => title.startsWith('SW')))] check('SW/Tool removed from the sidebar', !titles.includes('SW/Tool'), titles.join(', ')) check('SW/Popup, SW/Header and SW/Viewer remain', ['SW/Popup', 'SW/Header', 'SW/Viewer'].every(title => titles.includes(title))) } check('no page errors', errors.length === 0, errors.join(' | ')) await browser.close() await writeFile(path.join(out, 'report.json'), JSON.stringify({ storybook, reference, comparison, results, errors }, null, 2) + '\n') const failed = results.filter(item => item.result === 'FAIL').length console.log(`\n${results.length - failed}/${results.length} PASS`) process.exit(failed ? 1 : 0)