// Real SW consumer (HMDesignSwTest-viewer, isolated copy) — Dotum font, SW viewer chrome, DWG/DXF load, zoom/pan, // panels (open/close/Escape/drag/resize/scroll/slider/switch), header menus/More, responsive. Chrome (+ Firefox basic). // Usage: node viewer-verify-run.mjs [chromium|firefox] import { createRequire } from 'node:module' import path from 'node:path' import { mkdir, writeFile } from 'node:fs/promises' const require = createRequire(path.join('C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules', 'index.js')) const pw = require('playwright') const { PNG } = require('pngjs') const [base = 'http://127.0.0.1:5391', outArg = 'viewer-verify-out', engine = 'chromium'] = process.argv.slice(2) const out = path.resolve(outArg); await mkdir(out, { recursive: true }) const results = []; const errors = []; const missingFavicon = [] const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail !== '' ? ` — ${typeof detail === 'string' ? detail : JSON.stringify(detail)}` : ''}`) } const browser = engine === 'firefox' ? await pw.firefox.launch({ headless: true }) : await pw.chromium.launch({ headless: true, executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe' }) const newPage = async (width = 1600, height = 1000) => { const page = await browser.newPage({ viewport: { width, height } }) page.on('pageerror', e => errors.push(`pageerror ${e.message}`)) page.on('console', m => { if (m.type() === 'error' && !/favicon|Failed to load resource/.test(m.text())) errors.push(`console ${m.text()}`) }) page.on('response', r => { if (r.status() >= 400) (r.url().endsWith('/favicon.ico') ? missingFavicon : errors).push(`HTTP ${r.status()} ${r.url()}`) }) return page } const status = page => page.evaluate(() => document.querySelector('.hm-sw-viewer-status')?.getAttribute('data-state')) const waitLoaded = async (page, timeout = 60000) => { await page.waitForFunction(() => { const s = document.querySelector('.hm-sw-viewer-status')?.getAttribute('data-state'); return s && s !== 'loading' && s !== 'idle' }, null, { timeout }).catch(() => {}); await page.waitForTimeout(800); return status(page) } const canvasStats = async page => { const buf = await page.locator('.viewer-canvas').screenshot(); const png = PNG.sync.read(buf); const seen = new Map(); let n = 0; for (let i = 0; i < png.data.length; i += 16) { const k = (png.data[i] >> 4) << 8 | (png.data[i + 1] >> 4) << 4 | png.data[i + 2] >> 4; seen.set(k, (seen.get(k) ?? 0) + 1); n++ } const top = Math.max(...seen.values()); return { colors: seen.size, nonBg: +(1 - top / n).toFixed(4), buf } } const diffRatio = (a, b) => { const A = PNG.sync.read(a), B = PNG.sync.read(b); if (A.width !== B.width || A.height !== B.height) return 1; let d = 0; for (let i = 0; i < A.data.length; i += 4) if (Math.abs(A.data[i] - B.data[i]) + Math.abs(A.data[i + 1] - B.data[i + 1]) + Math.abs(A.data[i + 2] - B.data[i + 2]) > 30) d++; return d / (A.data.length / 4) } const fontsOf = (page, sel) => page.evaluate(sel => { const m = {}; for (const r of document.querySelectorAll(sel)) for (const e of [r, ...r.querySelectorAll('*')]) { if (e.closest('svg')) continue; if (![...e.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()) && !/^(BUTTON|INPUT)$/.test(e.tagName)) continue; const f = getComputedStyle(e).fontFamily.split(',')[0].replace(/"/g, ''); m[f] = (m[f] ?? 0) + 1 } return m }, sel) const page = await newPage() await page.goto(base); await page.waitForSelector('.hm-sw-viewer-header'); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(500) // --- Font / theme const headerFonts = await fontsOf(page, '.hm-sw-viewer-header') check('Font: SWViewerHeader text = Dotum (SW default)', Object.keys(headerFonts).length > 0 && Object.keys(headerFonts).every(f => f === 'Dotum' || f === 'ui-monospace'), headerFonts) if (engine === 'chromium') { const cdp = await page.context().newCDPSession(page); await cdp.send('DOM.enable'); await cdp.send('CSS.enable') const { root } = await cdp.send('DOM.getDocument', { depth: -1 }); const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: '.hm-sw-viewer-header *' }) const agg = {}; for (const id of nodeIds) { const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId: id }); for (const f of fonts) agg[f.familyName] = (agg[f.familyName] ?? 0) + f.glyphCount } check('Font: rendered platform font in header = Dotum (installed, no fallback)', Object.keys(agg).length > 0 && Object.keys(agg).every(f => f === 'Dotum'), agg) } const tokens = await page.evaluate(() => { const s = getComputedStyle(document.querySelector('.hm-sw-viewer-header')); return { font: s.getPropertyValue('--hm-sw-header-font-family').trim(), bg: s.getPropertyValue('--hm-sw-viewer-header-background').trim() || s.backgroundColor } }) check('Theme: SW token --hm-sw-header-font-family = Dotum stack', tokens.font.startsWith('Dotum'), tokens) const webFont = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--hm-font-family-base').trim()) check('Web UI token --hm-font-family-base unchanged (Noto Sans KR)', webFont.startsWith('"Noto Sans KR"'), webFont) await page.screenshot({ path: path.join(out, `${engine}-01-initial.png`) }) // --- DXF / DWG loading via the app's own ?model= path and the header sample menu const loads = {} for (const [label, url] of [['simple.dxf', '/samples/simple.dxf'], ['BasicSample.dwg', '/samples/BasicSample.dwg'], ['civil.dwg', '/samples/civil.dwg']]) { const p = await newPage() const t0 = Date.now(); await p.goto(`${base}/?model=${encodeURIComponent(url)}`); await p.waitForSelector('.hm-sw-viewer-header') const st = await waitLoaded(p, 120000); const stats = await canvasStats(p) const name = await p.evaluate(() => document.querySelector('.hm-sw-viewer-header__document-name')?.textContent?.trim() ?? '') const tabs = await p.locator('.hm-sw-viewer-header__tab').count() loads[label] = { status: st, ms: Date.now() - t0, colors: stats.colors, nonBg: stats.nonBg, name, tabs } check(`Load ${label}: status ready + drawing pixels`, st && !/error/i.test(st) && stats.nonBg > 0.002, loads[label]) await p.screenshot({ path: path.join(out, `${engine}-02-load-${label}.png`) }) await p.close() } // --- Sample menu (header dropdown) + Escape, then interactions on civil.dwg await page.goto(`${base}/?model=${encodeURIComponent('/samples/civil.dwg')}`); await waitLoaded(page, 120000) const before = await canvasStats(page) // zoom (wheel) and pan (drag) on the canvas const box = await page.locator('.viewer-canvas').boundingBox() await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.wheel(0, -600); await page.waitForTimeout(600) const zoomed = await canvasStats(page) check('Viewer: wheel zoom changes the drawing', diffRatio(before.buf, zoomed.buf) > 0.01, diffRatio(before.buf, zoomed.buf).toFixed(3)) await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down({ button: 'middle' }); await page.mouse.move(box.x + box.width / 2 + 150, box.y + box.height / 2 + 80, { steps: 8 }); await page.mouse.up({ button: 'middle' }); await page.waitForTimeout(400) let panned = await canvasStats(page) if (diffRatio(zoomed.buf, panned.buf) <= 0.01) { await page.mouse.move(box.x + 300, box.y + 300); await page.mouse.down(); await page.mouse.move(box.x + 450, box.y + 380, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(400); panned = await canvasStats(page) } check('Viewer: drag pan changes the view', diffRatio(zoomed.buf, panned.buf) > 0.01, diffRatio(zoomed.buf, panned.buf).toFixed(3)) await page.keyboard.press('f'); await page.waitForTimeout(500) const fitted = await canvasStats(page) check('Viewer: F (fit) restores the full view', diffRatio(before.buf, fitted.buf) < diffRatio(before.buf, panned.buf), `${diffRatio(before.buf, fitted.buf).toFixed(3)} < ${diffRatio(before.buf, panned.buf).toFixed(3)}`) // space tabs (model / layouts) const tabCount = await page.locator('.hm-sw-viewer-header__tab').count() if (tabCount > 1) { const other = page.locator('.hm-sw-viewer-header__tab:not([data-state="active"])').first(); const label = (await other.textContent()).trim(); await other.click(); await page.waitForTimeout(1500); const s2 = await canvasStats(page); const active = await page.locator('.hm-sw-viewer-header__tab[data-state="active"]').textContent(); check('Header: space tab switch (inactive → active) re-renders the canvas', active.trim() === label && diffRatio(fitted.buf, s2.buf) > 0.01, `${tabCount} tabs, now ${active.trim()}`); await page.locator('.hm-sw-viewer-header__tab:not([data-state="active"])').first().click(); await page.waitForTimeout(1000) } else check('Header: space tabs (only one space in civil.dwg)', true, `${tabCount} tab`) // Header menu: 샘플 dropdown await page.getByRole('button', { name: /샘플/ }).first().click() const menuOpen = await page.locator('.hm-sw-viewer-menu').count() const menuFonts = await fontsOf(page, '.hm-sw-viewer-menu') check('Header: 샘플 dropdown opens (SWViewerMenu) with Dotum', menuOpen > 0 && Object.keys(menuFonts).every(f => f === 'Dotum'), menuFonts) await page.screenshot({ path: path.join(out, `${engine}-03-sample-menu.png`) }) await page.keyboard.press('Escape'); await page.waitForTimeout(200) check('Header: Escape closes the dropdown', (await page.locator('.hm-sw-viewer-menu').count()) === 0) // load simple.dxf from the menu await page.getByRole('button', { name: /샘플/ }).first().click(); await page.getByRole('menuitem', { name: 'simple.dxf' }).click() const st2 = await waitLoaded(page) const name2 = await page.evaluate(() => document.querySelector('.hm-sw-viewer-header__document-name')?.textContent?.trim()) check('Header: sample menu loads simple.dxf', /simple\.dxf/.test(name2 ?? '') && !/error/i.test(st2 ?? ''), `${name2} / ${st2}`) // 2.5D switch const s25 = page.getByRole('switch', { name: '2.5D' }).first() if (await s25.count()) { const a = await canvasStats(page); await s25.click(); await page.waitForTimeout(800); const b = await canvasStats(page); check('Viewer: 2.5D switch toggles and re-renders', (await s25.getAttribute('aria-checked')) === 'true' && diffRatio(a.buf, b.buf) > 0.002, diffRatio(a.buf, b.buf).toFixed(4)); await s25.click(); await page.waitForTimeout(500) } // Panels: 설정 (options) — open, fonts, slider, switch, drag, resize, scroll, Escape/close await page.getByRole('button', { name: '설정' }).first().click(); await page.waitForTimeout(300) const panel = page.locator('.hm-sw-viewer-panel').filter({ hasText: '옵션' }).first() check('Panel: 설정 opens SWViewerPanel', await panel.isVisible()) const pf = await fontsOf(page, '.hm-sw-viewer-panel') check('Panel: text = Dotum (program list cells keep their monospace)', Object.keys(pf).every(f => f === 'Dotum' || f === 'ui-monospace'), pf) const slider = panel.locator('input[type="range"]').nth(1) const v0 = await slider.inputValue(); await slider.focus(); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); await page.waitForTimeout(150) const v1 = await slider.inputValue(); const num = await panel.locator('input[type="number"]').nth(1).inputValue() check('Panel: SWViewerSlider changes value and syncs the number input', Number(v1) > Number(v0) && Number(num) === Number(v1), `${v0} → ${v1}, input ${num}`) const sw = panel.getByRole('switch').nth(1); const c0 = await sw.getAttribute('aria-checked'); await sw.click(); const c1 = await sw.getAttribute('aria-checked'); await sw.click() check('Panel: SWViewerSwitch toggles (그리드)', c0 !== c1, `${c0} → ${c1}`) const pb0 = await panel.boundingBox(); const title = panel.locator('.hm-sw-property__title').first() const tb = await title.boundingBox(); await page.mouse.move(tb.x + 40, tb.y + tb.height / 2); await page.mouse.down(); await page.mouse.move(tb.x - 160, tb.y + 120, { steps: 10 }); await page.mouse.up(); await page.waitForTimeout(200) const pb1 = await panel.boundingBox() check('Panel: title drag moves the panel', Math.abs(pb1.x - pb0.x) > 100 && Math.abs(pb1.y - pb0.y) > 60, `(${Math.round(pb0.x)},${Math.round(pb0.y)}) → (${Math.round(pb1.x)},${Math.round(pb1.y)})`) const grip = panel.locator('.hm-sw-viewer-panel__resize') if (await grip.count()) { const gb = await grip.boundingBox(); await page.mouse.move(gb.x + gb.width / 2, gb.y + gb.height / 2); await page.mouse.down(); await page.mouse.move(gb.x + 80, gb.y - 160, { steps: 10 }); await page.mouse.up(); await page.waitForTimeout(200); const pb2 = await panel.boundingBox(); check('Panel: resize grip changes size', Math.round(pb2.width - pb1.width) > 40 && Math.round(pb1.height - pb2.height) > 80, `${Math.round(pb1.width)}×${Math.round(pb1.height)} → ${Math.round(pb2.width)}×${Math.round(pb2.height)}`) } else check('Panel: resize grip (not resizable in this program)', true, 'resizable=false') const body = panel.locator('.hm-sw-property__body') const sc = await body.evaluate(e => { const before = e.scrollTop; e.scrollTop = 9999; return { scrollable: e.scrollHeight > e.clientHeight, before, after: e.scrollTop, overflowX: e.scrollWidth > e.clientWidth + 1 } }) check('Panel: body scrolls vertically, no horizontal overflow', sc.scrollable && sc.after > sc.before && !sc.overflowX, sc) const clipped = await panel.evaluate(p => [...p.querySelectorAll('*')].filter(e => { const own = [...e.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()); if (!own) return false; const cs = getComputedStyle(e); if (cs.overflowX === 'visible' && cs.textOverflow !== 'ellipsis') return false; const r = document.createRange(); r.selectNodeContents(e); return r.getBoundingClientRect().width > e.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) + 0.5 }).map(e => e.textContent.trim().slice(0, 30))) check('Panel: no clipped text after Dotum', clipped.length === 0, clipped) await page.screenshot({ path: path.join(out, `${engine}-04-options-panel.png`) }) await panel.locator('input[type="number"]').first().focus(); await page.keyboard.press('Escape'); await page.waitForTimeout(200) const afterEsc = await panel.isVisible().catch(() => false) if (afterEsc) { await panel.getByRole('button', { name: /닫기/ }).first().click(); await page.waitForTimeout(200) } check(`Panel: closes (${afterEsc ? 'close button — Escape is not wired by the program for non-modal panels' : 'Escape'})`, !(await panel.isVisible().catch(() => false))) // 레이어 / 속성 panels + 정보(modal) Escape await page.getByRole('button', { name: '레이어' }).first().click(); await page.waitForTimeout(300) const lp = page.locator('.hm-sw-viewer-panel').filter({ hasText: '레이어' }).first() check('Panel: 레이어 opens with layer rows', (await lp.isVisible()) && (await lp.locator('li').count()) > 0, `${await lp.locator('li').count()} rows`) await page.getByRole('button', { name: '레이어' }).first().click(); await page.waitForTimeout(200) await page.getByRole('button', { name: '정보 및 라이선스' }).click(); await page.waitForTimeout(300) const about = page.locator('.hm-sw-viewer-panel[data-modal]').first() const aboutOpen = await about.isVisible().catch(() => false) await page.keyboard.press('Escape'); await page.waitForTimeout(200) check('Panel: 정보 (modal) opens and Escape closes it', aboutOpen && !(await about.isVisible().catch(() => false)), `open=${aboutOpen}`) // Responsive header: More at narrow widths, no horizontal overflow for (const w of [1920, 1180, 900, 560, 375]) { await page.setViewportSize({ width: w, height: 900 }); await page.waitForTimeout(400) const r = await page.evaluate(() => { const h = document.querySelector('.hm-sw-viewer-header'); return { overflow: h.scrollWidth > h.clientWidth + 1, doc: document.documentElement.scrollWidth > innerWidth + 1, more: !!document.querySelector('.hm-sw-viewer-header__more') } }) check(`Responsive ${w}: header/page no horizontal overflow${r.more ? ' (More shown)' : ''}`, !r.overflow && !r.doc, r) if (w === 900 || w === 375) { await page.screenshot({ path: path.join(out, `${engine}-05-${w}.png`) }) if (r.more) { await page.locator('.hm-sw-viewer-header__more').click(); await page.waitForTimeout(200); const items = await page.locator('.hm-sw-viewer-menu [role="menuitem"], .hm-sw-viewer-menu [role="menuitemcheckbox"], .hm-sw-viewer-menu button').count(); check(`Responsive ${w}: More opens with collapsed items`, items > 0, `${items} items`); await page.screenshot({ path: path.join(out, `${engine}-06-more-${w}.png`) }); await page.keyboard.press('Escape') } } } check('Console / page / HTTP errors: none (favicon.ico 404 = program has no icon, pre-existing)', errors.length === 0, { errors: errors.slice(0, 6), favicon404: missingFavicon.length }) await browser.close() await writeFile(path.join(out, `${engine}-result.json`), JSON.stringify({ base, engine, loads, results, errors }, null, 2)) console.log(`${results.filter(r => r.result === 'PASS').length}/${results.length} PASS`)