Files
HMDesign/packages/sw/tests/text-weight.browser.mjs
T
shkim4 ef69f5143b test(sw): align browser tests and theme story with Noto Sans KR and Bold
Font expectations move back to Noto Sans KR (Dotum becomes the theme
inheritance verification value), viewer labels expect 700, and the new
text-weight.browser.mjs checks the requested Bold scope and outlines.
2026-10-20 18:17:50 +09:00

167 lines
16 KiB
JavaScript

// 2026-10-20 SW text weight request: Bold only on the named texts, with the black text outline where asked.
// Header file name (700) · Composed Controls field values (700) vs SWHeader field values (400) · Footer 보조기능 / 명령어
// titles and category tabs (700, 전체보기 / Model · Layout / commands unchanged) · Viewer header file name, labels and
// Model / Layout tabs (700, More kept) · recent files name / path / preview title (700 + outline). Hover keeps the
// text in place. Usage: build Storybook first, then node packages/sw/tests/text-weight.browser.mjs [evidence-dir]
import { createRequire } from 'node:module'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const { chromium } = require('playwright')
const out = path.resolve(process.argv[2] ?? 'docs/sw/text-weight-qa'); await mkdir(out, { recursive: true })
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : JSON.stringify(d).slice(0, 500)) }
const report = {}
let errors = []
const page = await browser.newPage({ viewport: { width: 1920, height: 1100 } })
page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
const goto = async id => { errors = []; await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`); await page.locator('#storybook-root > *').first().waitFor(); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(300) }
const away = async () => { await page.mouse.move(2, 2); await page.waitForTimeout(90) }
const OUTLINE_14 = 'rgb(0, 0, 0) 1.3px 0px 0px', OUTLINE_12 = 'rgb(0, 0, 0) 1px 0px 0px'
// Every shown element of a selector: weight, size, color, outline start, text rect, box rect, cut.
const styles = sel => page.locator(sel).evaluateAll(list => list.filter(e => e.getClientRects().length && e.textContent.trim()).map(e => {
const c = getComputedStyle(e), g = document.createRange(); g.selectNodeContents(e); const t = g.getBoundingClientRect(), b = e.getBoundingClientRect()
return { text: e.textContent.trim(), weight: c.fontWeight, size: c.fontSize, family: c.fontFamily.split(',')[0], color: c.color, outline: c.textShadow.split(', rgb')[0], textW: Math.round(t.width * 100) / 100, boxW: Math.round(b.width * 100) / 100, cut: t.width > b.width + 0.05 }
}))
const rect = loc => loc.evaluate(e => { const g = document.createRange(); g.selectNodeContents(e); const t = g.getBoundingClientRect(), b = e.getBoundingClientRect(); return [t.x, t.y, t.width, t.height, b.x, b.y, b.width, b.height].map(v => Math.round(v * 100) / 100).join(',') })
async function stableOnHover(name, target, text) {
await target.scrollIntoViewIfNeeded(); await away()
const d = await rect(text), w0 = await text.evaluate(e => getComputedStyle(e).fontWeight)
await target.hover(); await page.waitForTimeout(110)
const h = await rect(text), w1 = await text.evaluate(e => getComputedStyle(e).fontWeight)
await away(); const d2 = await rect(text)
check(`${name}: hover keeps the text place, size and weight (${w0})`, d === h && d === d2 && w0 === w1, { d, h, d2, w0, w1 })
}
// ---------- 1. Header ----------
for (const id of ['sw-header--overview', 'sw-header--document-open', 'sw-header--interactive-states']) {
await goto(id)
const names = await styles('.hm-sw-header .hm-sw-header__document-name')
check(`Header ${id}: file name Noto Sans KR 14px 700 + 1.3px outline`, names.length > 0 && names.every(n => n.weight === '700' && n.size === '14px' && n.family === '"Noto Sans KR"' && n.outline === OUTLINE_14), names.slice(0, 3))
const values = await styles('.hm-sw-header .hm-sw-header__field-value')
report[`headerValues:${id}`] = values
check(`Header ${id}: SWHeader field values stay Regular (400)`, values.length > 0 && values.every(v => v.weight === '400'), values.filter(v => v.weight !== '400'))
}
await goto('sw-header--overview')
const docButton = page.locator('.hm-sw-header__document-button').first()
await stableOnHover('Header file name', docButton, docButton.locator('.hm-sw-header__document-name'))
// SWHeader More (narrow): fields moved into the More menu are still SWHeader fields (400).
await goto('sw-header--responsive-more')
const narrow = page.locator('.hm-sw-header').filter({ visible: true }).nth(2)
const moreTrigger = narrow.locator('.hm-sw-header__more-trigger')
if (await moreTrigger.count()) { await moreTrigger.click(); await page.waitForTimeout(200) }
const moreValues = await styles('.hm-sw-header__field-value')
check('Header More: values shown with the More menu open stay 400 (none turn Bold)', moreValues.length > 0 && moreValues.every(v => v.weight === '400'), moreValues.filter(v => v.weight !== '400'))
await page.keyboard.press('Escape')
// Composed Controls: standalone menu fields (outside SWHeader) are Bold; labels and the font sample unchanged.
await goto('sw-header--composed-controls')
const composed = await styles('[data-composed-controls] .hm-sw-header__field-value')
report.composedValues = composed
check('Composed Controls: every field value Noto Sans KR 14px 700 + 1.3px outline', composed.length >= 7 && composed.every(v => v.weight === '700' && v.size === '14px' && v.outline === OUTLINE_14), composed)
const composedLabels = await styles('[data-composed-controls] .hm-sw-header__field-label')
check('Composed Controls: field labels stay 12px 400', composedLabels.every(l => l.weight === '400' && l.size === '12px'), composedLabels)
report.composedCut = composed.filter(v => v.cut).map(v => `${v.text} (${v.textW} / ${v.boxW}px)`)
console.log('INFO Composed Controls values wider than their (unchanged) box at 700:', report.composedCut.join(', ') || 'none')
const fields = page.locator('[data-composed-controls] .hm-sw-header__field')
for (let i = 0, n = await fields.count(); i < n; i++) {
const f = fields.nth(i), v = f.locator('.hm-sw-header__field-value').first()
await stableOnHover(`Composed field ${await f.getAttribute('data-menu')}`, f, v)
}
// ---------- 2. Footer ----------
await goto('sw-footer--full-footer')
const work = page.locator('.sw-footer-story-workspace .hm-sw-footer')
for (const side of ['start', 'end']) {
const btn = work.locator(side === 'end' ? '.hm-sw-footer-side[data-side="end"]' : '.hm-sw-footer-side:not([data-side="end"])')
const read = () => btn.evaluate(e => [e.querySelector('.hm-sw-footer-side__title'), e.querySelector('.hm-sw-footer-side__subtitle')].map(t => { const c = getComputedStyle(t); return [t.textContent, c.fontWeight, c.fontSize, c.color, c.textShadow.split(', rgb')[0]].join('|') }))
await away(); const d = await read()
await btn.hover(); await page.waitForTimeout(110); const h = await read(); await away()
check(`Footer ${side === 'end' ? '명령어' : '보조기능'}: title 700 (Default + Hover), 전체보기 keeps 400 + 1px outline`, d[0].split('|')[1] === '700' && h[0].split('|')[1] === '700' && d[1].split('|')[1] === '400' && h[1].split('|')[1] === '400' && d[1].endsWith(OUTLINE_12) && h[1].split('|')[3] === 'rgb(255, 92, 0)', { d, h })
await stableOnHover(`Footer side ${side} title`, btn, btn.locator('.hm-sw-footer-side__title'))
await stableOnHover(`Footer side ${side} 전체보기`, btn, btn.locator('.hm-sw-footer-side__subtitle'))
}
const tabs = await styles('.sw-footer-story-workspace .hm-sw-footer-menu__tab')
report.footerTabs = tabs
check(`Footer: every category tab (${tabs.length}) 14px 700 + 1.3px outline`, tabs.length >= 10 && tabs.every(t => t.weight === '700' && t.size === '14px' && t.outline === OUTLINE_14), tabs.filter(t => t.weight !== '700'))
const tabLoc = work.locator('.hm-sw-footer-menu__tab')
await stableOnHover('Footer tab 수정 (Default → Hover)', tabLoc.nth(1), tabLoc.nth(1))
// Selected: the tab's text sits at the same place inside its own box, weight unchanged.
const inBox = loc => loc.evaluate(e => { const g = document.createRange(); g.selectNodeContents(e); const t = g.getBoundingClientRect(), b = e.getBoundingClientRect(); return [t.x - b.x, t.y - b.y, t.width, b.width, b.height, getComputedStyle(e).fontWeight].map(v => typeof v === 'number' ? Math.round(v * 100) / 100 : v).join(',') })
const before = await inBox(tabLoc.nth(1)); await tabLoc.nth(1).click(); await away(); const after = await inBox(tabLoc.nth(1))
check('Footer tab 수정 selected: text place / size / weight inside the tab unchanged', before === after && await tabLoc.nth(1).getAttribute('aria-selected') === 'true', { before, after })
const pocket = await work.locator('.hm-sw-footer-menu').evaluate(m => { const t = m.querySelector('[aria-selected="true"]').getBoundingClientRect(), y = t.y + 4
const under = x => document.elementsFromPoint(x, y).find(e => e.matches('.hm-sw-footer__vacant, .hm-sw-footer__group'))?.className
return [under(t.x + 3), under(t.x + t.width / 2), under(t.right - 3)] })
check('Footer: 수정 pocket surface stays continuous under the Bold tab', pocket.every(p => p === 'hm-sw-footer__group'), pocket)
await tabLoc.nth(0).click(); await away()
const keep = await styles('.sw-footer-story-workspace :is(.hm-sw-footer-tab__select, .hm-sw-footer-tool__label, .hm-sw-footer-aux__label)')
check('Footer: Model / Layout, command and auxiliary labels keep 400', keep.length > 0 && keep.every(k => k.weight === '400'), keep.filter(k => k.weight !== '400'))
const area = await work.locator('.hm-sw-footer-menu__viewport').evaluate(e => ({ w: Math.round(e.getBoundingClientRect().width * 10) / 10, h: Math.round(e.getBoundingClientRect().height), content: e.scrollWidth, steps: [...e.parentElement.querySelectorAll('.hm-sw-footer-menu__step')].filter(s => s.getClientRects().length).length }))
report.footerCategoryArea = area
console.log('INFO Footer category area at 1920:', JSON.stringify(area))
// The approved category row is 24px high (measured before and after the Bold change, 2026-10-20).
check('Footer: category row height unchanged (24px)', area.h === 24, area)
check('Footer: ◀ ▶ only when the categories overflow their area', (area.content > area.w + 0.5) === (area.steps === 2), area)
await page.screenshot({ path: path.join(out, 'footer.png'), clip: { x: 0, y: 940, width: 1920, height: 160 } })
// ---------- 3. Viewer header ----------
await goto('sw-viewer--viewer-header')
const vh = page.locator('.hm-sw-viewer-header').first()
const vNames = await styles('.hm-sw-viewer-header >> nth=0 >> :is(.hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab)')
report.viewer = vNames
const want = ['파일 열기', '샘플', '전체 보기', '레이어', '설정', '속성']
const labels = vNames.map(v => v.text)
check('Viewer: file name, 파일열기 · 샘플 · 전체보기 · 레이어 · 설정 · 속성, Model · Layout are 14px 700 + 1.3px outline', want.every(w => labels.includes(w)) && labels.some(t => t.startsWith('Model')) && labels.some(t => t.startsWith('Layout')) && vNames.every(v => v.weight === '700' && v.size === '14px' && v.outline === OUTLINE_14), vNames)
const counts = await styles('.hm-sw-viewer-header >> nth=0 >> .hm-sw-viewer-header__count')
check('Viewer: tab counts keep 11px 400', counts.length > 0 && counts.every(c => c.weight === '400' && c.size === '11px'), counts)
for (const t of ['파일 열기', '레이어']) {
const lab = vh.locator('.hm-sw-viewer-header__label', { hasText: t }).first()
await stableOnHover(`Viewer ${t}`, lab.locator('xpath=ancestor::button[1]'), lab)
}
const tab = vh.locator('.hm-sw-viewer-header__tab', { hasText: 'Layout' }).first()
await stableOnHover('Viewer Layout tab', tab, tab)
// More stays: narrow header shows the More button, it opens and lists the moved items.
await vh.evaluate(e => { e.style.width = '700px' }); await page.waitForTimeout(200)
const more = vh.getByRole('button', { name: /추가 도구/ })
check('Viewer: More button shown at a narrow width', await more.count() === 1 && await more.isVisible())
await more.click(); await page.waitForTimeout(200)
const moreItems = await page.locator('.hm-sw-viewer-menu [role="menuitem"], .hm-sw-viewer-menu [role="menuitemradio"], .hm-sw-viewer-menu [role="menuitemcheckbox"]').count()
check('Viewer: More opens with its entries', moreItems > 0, moreItems)
await page.keyboard.press('Escape'); await page.waitForTimeout(100)
check('Viewer: Escape closes More', await page.locator('.hm-sw-viewer-menu').filter({ visible: true }).count() === 0)
await vh.evaluate(e => { e.style.width = '' })
check('Viewer: no page errors', errors.length === 0, errors)
// ---------- 4. Recent files + preview ----------
await goto('sw-header--home-menu')
const recentTile = page.locator('.hm-sw-home-menu > .hm-sw-home-menu__sections .hm-sw-home-menu__item', { hasText: '최근파일' }).filter({ visible: true }).first()
if (await recentTile.getAttribute('data-active') !== 'true') { await recentTile.click(); await page.waitForTimeout(300) }
const rNames = await styles('.hm-sw-recent-files__name'), rPaths = await styles('.hm-sw-recent-files__path')
report.recent = { names: rNames.slice(0, 3), paths: rPaths.slice(0, 3) }
check(`Recent files: names (${rNames.length}) 14px 700 + 1.3px outline`, rNames.length >= 10 && rNames.every(n => n.weight === '700' && n.size === '14px' && n.outline === OUTLINE_14), rNames.slice(0, 2))
check(`Recent files: paths (${rPaths.length}) 12px 700 + 1px outline, ellipsis kept`, rPaths.length >= 10 && rPaths.every(p => p.weight === '700' && p.size === '12px' && p.outline === OUTLINE_12), rPaths.slice(0, 2))
const row = page.locator('.hm-sw-recent-files__row').first()
const rowGeo = () => row.evaluate(r => [r.getBoundingClientRect().height, ...[...r.querySelectorAll('.hm-sw-recent-files__name, .hm-sw-recent-files__path')].map(e => Math.round(e.getBoundingClientRect().width))].join(','))
const rowBefore = await rowGeo()
await stableOnHover('Recent file name', row, row.locator('.hm-sw-recent-files__name'))
await row.hover(); await page.waitForTimeout(250)
const preview = await page.locator('.hm-sw-recent-files__preview').evaluate(p => { const t = p.querySelector('.hm-sw-recent-files__preview-title'), c = getComputedStyle(t), b = p.getBoundingClientRect(), body = p.querySelector('.hm-sw-recent-files__preview-body').getBoundingClientRect()
return { title: t.textContent, weight: c.fontWeight, size: c.fontSize, color: c.color, outline: c.textShadow.split(', rgb')[0], titleW: t.getBoundingClientRect().width, overflow: c.textOverflow, w: b.width, h: b.height, bodyH: body.height } })
report.preview = preview
check('Recent preview: title 14px 700 + 1.3px outline, width 294 with ellipsis, body 243px', preview.weight === '700' && preview.size === '14px' && preview.outline === OUTLINE_14 && preview.titleW === 294 && preview.overflow === 'ellipsis' && preview.bodyH === 243, preview)
await page.screenshot({ path: path.join(out, 'recent-files.png'), clip: { x: 0, y: 0, width: 1100, height: 900 } })
check('Recent files: rows keep 50px and their text widths', rowBefore === await rowGeo() && rowBefore.startsWith('50,'), rowBefore)
await row.press('Enter').catch(() => {})
check('Recent files / preview: no page errors', errors.length === 0, errors)
await writeFile(path.join(out, 'text-weight.json'), JSON.stringify({ checks, report }, null, 2))
const failed = checks.filter(c => !c.ok).length
console.log(`${checks.length - failed}/${checks.length} PASS`)
await browser.close(); server.close()
process.exitCode = failed ? 1 : 0