Deploy Storybook / deploy (push) Successful in 1m10s
- SW Header: the five field previews hide and return together in one measured step; from 760 to 1920px the groups from 레이어 to 치수스타일 share the room left before 속성보기, above 1920px they keep the 1920 widths (controls bars and < 760px unchanged) - SW Viewer Header Model / Layout: long names ellipsize in equal buttons (minmax(0, 1fr), name counted at --hm-sw-viewer-tab-name-basis) so they never change the box or the measured steps; green outer outline and its tokens removed; 378px / 42px, text-shadow and state unchanged - SWViewerStatus: the label keeps the width of its widest real state text (idle keeps its icon slot), so a state change never moves the header; with too little room it ellipsizes after the file name gives way - Popups: body inset shadow removed, title SVG drop shadow removed, viewer panel title icons use --hm-sw-viewer-panel-icon-color (#c7bda9) - Storybook: SW/Viewer › Model / Layout story removed - AI guide (docs/ai + package copies): design-only changes for every component, content limits for Submodule consumers (Header excluded), Submodule usage rules; VIEWER / DESIGN_RULES synced - viewer.browser.mjs: explicit 1920 / 1440 labels and approved 1280 icon-only expectations
529 lines
44 KiB
JavaScript
529 lines
44 KiB
JavaScript
// 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 <storybook-url> <reference-url|-> <out-dir>
|
|
// 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
|
|
}
|
|
// 2026-10-23: waits for a visible header (the Responsive Story's first, 1920px row is hidden below a 2000px 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:visible'); 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()
|
|
}
|
|
// 2026-10-23: the Model / Layout buttons share one width (their field face widens up to 378px with room), so they and
|
|
// their field are left out of the size comparison with the reference. Height: the box matches the field group (42px), so the buttons are 37.5px (margin 1px top and sides, 1.5px bottom; geometry rounds to 38).
|
|
check('geometry tabs: equal widths, 37.5px high (box = field group 42px)', comparison.package.tab.length === 2 && comparison.package.tab.every(item => item.h === 38 && Math.abs(item.w - comparison.package.tab[0].w) <= 1), comparison.package.tab.map(item => `${item.w}x${item.h}`).join(' '))
|
|
if (comparison.reference) {
|
|
// Same item = same size (positions shift with the file-name text width, which differs by sample data).
|
|
const noTabs = items => items.filter(item => !item.text.startsWith('Model'))
|
|
for (const key of ['button', 'toggle', 'switchField', 'field']) {
|
|
const pick = key === 'field' ? noTabs : items => items
|
|
const a = pick(comparison.reference[key]).map(item => `${item.w}x${item.h}`), b = pick(comparison.package[key]).map(item => `${item.w}x${item.h}`)
|
|
// field (without the tabs field): same width, height within 1px.
|
|
const [ref, pkg] = [pick(comparison.reference[key]), pick(comparison.package[key])]
|
|
const within = key === 'field' ? ref.every((item, index) => pkg[index] && item.w === pkg[index].w && Math.abs(item.h - pkg[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 (2026-10-21 rule, VIEWER.md: the #266651 face was removed): text / icon #FFC600, no face, same size and
|
|
// position. 전체 보기 left the header row for More, so the panel toggle is hovered.
|
|
const hovered = root.getByRole('button', { name: '레이어', exact: true })
|
|
const beforeHover = await box(hovered)
|
|
await hovered.hover()
|
|
const hoverStyle = await hovered.evaluate(element => { const style = getComputedStyle(element); return `${style.color} ${style.backgroundColor}` })
|
|
const afterHover = await box(hovered)
|
|
check('hover: #FFC600 text, no face, same box', hoverStyle === 'rgb(255, 198, 0) rgba(0, 0, 0, 0)' && JSON.stringify(beforeHover) === JSON.stringify(afterHover), `${hoverStyle} ${JSON.stringify(afterHover)}`)
|
|
await page.mouse.move(0, 0)
|
|
// 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. 2026-10-22: per-layer bulb buttons (SWLayerVisibilityIcon) + one all-layers bulb on top
|
|
// (role="switch"; ON only when every layer is shown; a partial state reads OFF and a click shows all).
|
|
const face = locator => locator.evaluate(element => [getComputedStyle(element).backgroundColor, getComputedStyle(element).color].join(' '))
|
|
const allSwitch = layerPanel.getByRole('switch', { name: '전체 레이어' })
|
|
const switchOn = async () => (await allSwitch.getAttribute('aria-checked')) === 'true'
|
|
const hiddenRows = () => layerPanel.locator('li[data-visible="false"]').count()
|
|
check('layer rows use the header bulb icon', (await layerPanel.locator('li button .hm-sw-header__layer-icon--composite').count()) === (await layerPanel.locator('li').count()) && (await layerPanel.locator('li button svg').count()) === 0)
|
|
check('all-layers switch ON when all shown, no 전체 켜기 / 끄기 buttons', await switchOn() && (await layerPanel.getByRole('button', { name: /전체 (켜기|끄기)/ }).count()) === 0)
|
|
check('all-layers control is the bulb icon, no SWViewerSwitch', (await allSwitch.locator('.hm-sw-header__layer-icon--composite').count()) === 1 && (await layerPanel.locator('.hm-sw-viewer-switch').count()) === 0)
|
|
await layerPanel.getByRole('button', { name: 'CR-TEXT 숨기기' }).click()
|
|
check('panel content callback', (await log(page)).includes('layer:CR-TEXT=false'))
|
|
check('one layer hidden -> switch OFF (partial)', !(await switchOn()) && (await hiddenRows()) === 1)
|
|
await allSwitch.click()
|
|
check('partial + switch -> all shown, switch ON', await switchOn() && (await hiddenRows()) === 0)
|
|
await allSwitch.click()
|
|
check('switch OFF -> all hidden', !(await switchOn()) && (await hiddenRows()) === (await layerPanel.locator('li').count()))
|
|
for (const button of await layerPanel.locator('li button').all()) await button.click()
|
|
check('each layer shown again -> switch ON (synced)', await switchOn() && (await hiddenRows()) === 0)
|
|
// 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 = --hm-sw-viewer-hover face', confirmHover === 'rgb(38, 102, 81) rgb(255, 255, 255)', confirmHover)
|
|
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.
|
|
// 2026-10-23: rows are picked by their data-width (not by position). The Story's 1920px row is hidden below a 2000px
|
|
// page (sw-story-responsive-hidden), so the page is 2500px wide and every row is shown at its own maximum width.
|
|
const page = await newPage(2500, 1200)
|
|
await open(page, 'responsive')
|
|
const row = width => page.locator(`[data-width="${width}"] .hm-sw-viewer-header`)
|
|
const report = []
|
|
for (const width of [1920, 1440, 1280, 950, 860, 760, 520]) {
|
|
const header = row(width)
|
|
check(`responsive ${width}px row shown`, await header.isVisible())
|
|
const info = await header.evaluate(element => {
|
|
const rect = node => node.getBoundingClientRect()
|
|
const parts = [element.querySelector('.hm-sw-viewer-header__document'), ...element.querySelectorAll('.hm-sw-viewer-header__object > [data-group], .hm-sw-viewer-header__more, .hm-sw-viewer-header__actions')].filter(Boolean).map(rect)
|
|
const overlaps = parts.some((a, i) => parts.slice(i + 1).some(b => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 0.5))
|
|
const tabs = element.querySelector('.hm-sw-viewer-header__tabs')
|
|
const field = tabs?.parentElement
|
|
const tabWidths = tabs ? [...tabs.querySelectorAll('.hm-sw-viewer-header__tab')].map(tab => rect(tab).width) : []
|
|
// Name and count of a button never overlap (and the button never clips them).
|
|
const tabsFit = tabs ? [...tabs.querySelectorAll('.hm-sw-viewer-header__tab')].every(tab => {
|
|
const count = tab.querySelector('.hm-sw-viewer-header__count'), range = document.createRange()
|
|
range.selectNodeContents(tab.firstChild)
|
|
return tab.scrollWidth <= tab.clientWidth && (!count || range.getBoundingClientRect().right <= rect(count).left)
|
|
}) : true
|
|
return {
|
|
width: Math.round(rect(element).width),
|
|
overflow: element.scrollWidth - element.clientWidth,
|
|
objectOverflow: (() => { const object = element.querySelector('.hm-sw-viewer-header__object'); return object.scrollWidth - object.clientWidth })(),
|
|
overlaps,
|
|
outside: parts.some(part => part.right > rect(element).right + 0.5),
|
|
more: !!element.querySelector('.hm-sw-viewer-header__more'),
|
|
field: field ? Math.round(rect(field).width * 10) / 10 : null,
|
|
tabsEqual: tabWidths.every(width => Math.abs(width - tabWidths[0]) <= 0.5),
|
|
tabsFit,
|
|
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')),
|
|
// Buttons shown as icons only by the measured steps (name = aria-label / title) and labels drawn as text.
|
|
iconOnly: [...element.querySelectorAll('.hm-sw-viewer-header__button[data-icon-only=""]')].map(item => item.getAttribute('aria-label') || item.getAttribute('title') || item.textContent.trim()).sort(),
|
|
shownLabels: [...element.querySelectorAll('.hm-sw-viewer-header__label')].filter(item => rect(item).width > 0).map(item => item.textContent.trim()),
|
|
}
|
|
})
|
|
report.push({ rowWidth: width, ...info })
|
|
// Element capture: the lower rows are below the 1200px viewport (a page clip would fall outside the image).
|
|
await header.screenshot({ path: path.join(out, `package-responsive-${width}.png`) })
|
|
}
|
|
const at = width => report.find(item => item.rowWidth === width)
|
|
check('rows render at their own width', report.every(item => item.width === item.rowWidth), JSON.stringify(report.map(item => [item.rowWidth, item.width])))
|
|
check('no horizontal overflow, overlap or part outside the header at any row', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1 && !item.overlaps && !item.outside), JSON.stringify(report.map(item => [item.rowWidth, item.overflow, item.objectOverflow, item.overlaps, item.outside])))
|
|
// 2026-10-23: the Model / Layout field widens with the free room up to 378px (1920: 378), narrower with less room at
|
|
// the same step (1920 → 1440, both with every label); the buttons always share one width and keep name + count apart.
|
|
// With this Story's content 1920 / 1440 / 1280 show every item (no More); narrower rows move collapseBelow items
|
|
// (Model / Layout included) into More by the measured steps.
|
|
check('Model / Layout field: 378px at 1920, at most 378px and narrower with less room (1920 → 1440)', at(1920).field === 378 && report.every(item => item.field === null || item.field <= 378) && at(1440).field <= at(1920).field, JSON.stringify(report.map(item => [item.rowWidth, item.field])))
|
|
// 1920 / 1440 (unchanged): every button with its label, none icon only.
|
|
check('1920 / 1440: 전체 보기 · 레이어 · 설정 · 속성 labelled, no icon-only button', [1920, 1440].every(width => !at(width).iconOnly.length && ['전체 보기', '레이어', '설정', '속성'].every(name => at(width).shownLabels.includes(name))), JSON.stringify([1920, 1440].map(width => [width, at(width).shownLabels, at(width).iconOnly])))
|
|
// 1280 (2026-10-23, user-approved): the status label keeps the width of its widest text (SWViewerStatus), so at 1280
|
|
// the measured icon step turns exactly the three panel toggles into icons; 전체 보기 keeps its label, nothing is in More.
|
|
check('1280: exactly 레이어 · 설정 · 속성 icon only, 전체 보기 labelled, no More', JSON.stringify(at(1280).iconOnly) === JSON.stringify(['레이어', '설정', '속성'].sort()) && at(1280).shownLabels.includes('전체 보기') && !['레이어', '설정', '속성'].some(name => at(1280).shownLabels.includes(name)) && !at(1280).more, JSON.stringify([at(1280).shownLabels, at(1280).iconOnly, at(1280).more]))
|
|
check('Model / Layout buttons equal, name and count never overlap', report.every(item => item.tabsEqual && item.tabsFit))
|
|
check('1920 / 1440 / 1280: every item in the header, no More', [1920, 1440, 1280].every(width => !at(width).more && at(width).field && ['전체 보기', '2.5D', '어두운 배경', '레이어'].every(name => at(width).labels.includes(name))))
|
|
// 950 sits at the edge of the measured steps (icons only or More, depending on the font rendering); both fit.
|
|
check('950: icons only or More', at(950).more || !(await row(950).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
|
|
check('860 / 760 / 520: More shown', [860, 760, 520].every(width => at(width).more))
|
|
check('860: icons only + More', at(860).more && !(await row(860).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
|
|
check('520: icons only + More; panel toggles kept as icons (label → aria-label)', at(520).more && ['레이어', '설정', '속성'].every(name => at(520).labels.includes(name))
|
|
&& !(await row(520).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
|
|
check('no 파일 열기 / 샘플 header buttons at any width', report.every(item => !item.labels.includes('파일 열기') && !item.labels.includes('샘플')))
|
|
// 2026-10-23: a header first drawn wide and then narrowed in place (window resize) takes the same measured steps
|
|
// (icons, then More) without overflow, and gets every item back when widened again.
|
|
const resize = async (width, max) => page.evaluate(async ([width, max]) => {
|
|
const wrapper = document.querySelector(`[data-width="${width}"]`)
|
|
wrapper.style.maxWidth = max ? `${max}px` : ''
|
|
await new Promise(resolve => setTimeout(resolve, 400))
|
|
const header = wrapper.querySelector('.hm-sw-viewer-header')
|
|
return { overflow: header.scrollWidth - header.clientWidth, more: !!header.querySelector('.hm-sw-viewer-header__more'), labels: header.querySelectorAll('.hm-sw-viewer-header__object .hm-sw-viewer-header__label').length }
|
|
}, [width, max])
|
|
const shrink = []
|
|
for (const width of [1920, 1440]) {
|
|
for (const max of [1100, 1000, 900, 800]) shrink.push({ width, max, ...await resize(width, max) })
|
|
shrink.push({ width, max: 'restored', ...await resize(width, null) })
|
|
}
|
|
check('in-place shrink: no overflow, More once the room is short', shrink.every(item => item.overflow <= 0) && shrink.filter(item => item.max === 800).every(item => item.more), JSON.stringify(shrink.map(item => [item.width, item.max, item.overflow, item.more])))
|
|
check('in-place grow back: every label back, no More', shrink.filter(item => item.max === 'restored').every(item => !item.more && item.labels === at(item.width).labels.filter(label => ['전체 보기', '레이어', '설정', '속성'].includes(label)).length), JSON.stringify(shrink.filter(item => item.max === 'restored')))
|
|
const more = row(520).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')
|
|
// Model / Layout inside More: radio items with their counts, the current value checked.
|
|
const radios = () => moreMenu.getByRole('menuitemradio').evaluateAll(entries => entries.map(entry => `${entry.textContent.trim()}=${entry.getAttribute('aria-checked')}`))
|
|
const initial = await radios()
|
|
check('More: Model / Layout radios with counts, Model checked', initial.length === 2 && /^Model.*17,377.*=true$/.test(initial[0]) && /^Layout.*237.*=false$/.test(initial[1]), JSON.stringify(initial))
|
|
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 more.click()
|
|
const switched = await radios()
|
|
check('More: Layout checked after the switch, counts kept', switched.length === 2 && /^Model.*17,377.*=false$/.test(switched[0]) && /^Layout.*237.*=true$/.test(switched[1]), JSON.stringify(switched))
|
|
check('wide rows follow the same value (Layout selected, counts kept)', (await row(1440).getByRole('radio', { name: /Layout/ }).getAttribute('aria-checked')) === 'true'
|
|
&& (await row(1440).locator('.hm-sw-viewer-header__count').allInnerTexts()).join('|') === '17,377|237')
|
|
await page.getByRole('menuitemradio', { name: /Model/ }).click()
|
|
check('More radio back to Model', (await log(page)).includes('space=model') && (await row(1440).getByRole('radio', { name: /Model/ }).getAttribute('aria-checked')) === 'true')
|
|
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 (2026-10-23 rule, VIEWER.md): no logo = no logo area at all (no cap, no slant, no width; the first part starts
|
|
// at the header edge). With a logo: the SWHeader home shape (shared .hm-sw-header__home-bg slant) around the logo,
|
|
// 11px + the 1px header border + logo + 36px wide (79px with the 32px example logo), overlapping the next part by 17px.
|
|
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 header = element.querySelector('.hm-sw-viewer-header'), base = header.getBoundingClientRect()
|
|
const brand = header.querySelector('.hm-sw-viewer-header__brand'), mark = header.querySelector('.hm-sw-viewer-header__brand-mark')
|
|
const first = [...header.children].find(child => !child.matches('.hm-sw-viewer-header__brand, .hm-sw-viewer-header__logo-menu'))
|
|
return {
|
|
brand: !!brand, slant: !!brand?.querySelector('.hm-sw-header__home-bg'), text: brand?.textContent.trim() ?? '',
|
|
img: !!brand?.querySelector('img, .hm-sw-viewer-header__brand-mark svg'),
|
|
width: brand ? Math.round(brand.getBoundingClientRect().width) : 0, logoWidth: mark ? Math.round(mark.getBoundingClientRect().width) : 0,
|
|
logoX: mark ? Math.round(mark.getBoundingClientRect().x - base.x) : null,
|
|
firstX: Math.round(first.getBoundingClientRect().x - base.x), gap: parseFloat(getComputedStyle(header).columnGap),
|
|
height: Math.round(base.height),
|
|
fileOpenY: Math.round(header.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 logo area (no cap, slant or text)', !none.brand && !none.slant && none.text === '' && !none.img, JSON.stringify(none))
|
|
check('logo none: first part starts at the header edge (no width left)', none.firstX === 1, JSON.stringify(none))
|
|
for (const [name, item] of [['logoSrc', src], ['logo element', node]]) {
|
|
check(`${name}: slanted cap (SWHeader home shape) with the logo`, item.brand && item.slant && item.img, JSON.stringify(item))
|
|
check(`${name}: cap = 11 + logo + 36 (79px with the 32px logo), logo 12px from the header edge`, item.width === 11 + item.logoWidth + 36 && item.logoX === 12, JSON.stringify(item))
|
|
check(`${name}: next part overlapped by 17px`, item.firstX === 1 + item.width - 17 + item.gap, JSON.stringify(item))
|
|
}
|
|
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(' '))
|
|
{
|
|
// SWHeader keeps its 80px home area and slant without a logo (unlike the viewer).
|
|
const sw = await newPage(1920, 1000)
|
|
await sw.goto(`${storybook}/iframe.html?id=sw-header--logo-slot&viewMode=story`)
|
|
await sw.waitForSelector('[data-logo-case] .hm-sw-header:visible')
|
|
const homes = await sw.locator('[data-logo-case] .hm-sw-header').evaluateAll(headers => headers.map(header => {
|
|
const home = header.querySelector('.hm-sw-header__home')
|
|
return { width: home ? Math.round(home.getBoundingClientRect().width) : 0, slant: !!home?.querySelector('.hm-sw-header__home-bg') }
|
|
}))
|
|
check('SWHeader: 80px home area and slant with and without a logo', homes.length === 3 && homes.every(home => home.width === 80 && home.slant), JSON.stringify(homes))
|
|
await sw.close()
|
|
}
|
|
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)
|