SW Viewer Header (approved optional props, public export names unchanged at 170): - SWViewerHeader: logoMenu, documents, activeDocumentId, onDocumentSelect, onDocumentOpen, documentOpenLabel; measured responsive toggles (full -> icons -> More) with the SWHeader More / file-list styling. - SW AI guides, ExampleH, viewer/text-weight/theme-inheritance tests and the logo-document-menu tests with their QA record. SW Storybook cleanup (Story IDs and test fixtures kept): - Sidebar: Overview -> "SW Header" (first), 전체 Footer -> "SW Footer", Footer/Viewer responsive pages -> "Responsive More", Viewer content-variants -> "Settings Popup" (merges the settings popup page). - Hidden by the verification tag (direct URL kept): Header Dropdown, Document Open, Interactive States, Document Dropdown, Hidden Sub Controls, Home Menu standalone; Footer tool groups / custom content / parts; SW/Theme; Viewer menu groups / item states / variable size / settings panel / Header + Dropdown. - Removed demo UI: Overview size and callback output (demo data and selection only with &verification), Home Menu Figma node table and width demo, Header Responsive More reduced to 1440 / 768 / 390, Popup reopen button, property-panel comparison captions, Viewer context-menu demo (&verification only). - Tests: storybook-inventory records the hidden and renamed Stories; overview / range-cross-browser / viewer open the demo controls with &verification; integrated-popup reloads the Story instead of the reopen button; dropdown-consolidation asserts Dropdown is hidden from the sidebar and still opens by its direct URL.
230 lines
20 KiB
JavaScript
230 lines
20 KiB
JavaScript
// Chrome vs Firefox property-panel gauge (range) check on the running Storybook.
|
||
// Usage: node packages/sw/tests/range-cross-browser.browser.mjs <evidence-dir> [storybook-url]
|
||
// Chrome via Playwright; Firefox = the installed browser driven over WebDriver BiDi (no Playwright build needed).
|
||
// Storybook serves packages/sw/dist: build @hm-design/sw first.
|
||
// 2026-10-20: the Story is SW/Header › Overview (sw-header--overview). The former sw-header--property-panel was
|
||
// retired on 2026-10-19; Overview is the current Story with the same Header + property window (원 선택, opacity
|
||
// gauge 0.25, step 0.01) and host ReadOnly / Disabled. Differences from the retired Story, kept visible in the
|
||
// steps: Overview has no "외부 값" button for the opacity, so `host-0.8` commits 0.8 in the value input of the same
|
||
// row (host state → gauge, through onPropertyChange); its ReadOnly / Disabled switches cover every property, not
|
||
// only the opacity; a disabled gauge cannot take focus, so the disabled / readOnly steps try to focus the gauge
|
||
// itself and record whether it got focus.
|
||
import { mkdir, writeFile, rm } from 'node:fs/promises'
|
||
import { spawn } from 'node:child_process'
|
||
import os from 'node:os'
|
||
import path from 'node:path'
|
||
import { loadPlaywright } from './playwright-runtime.mjs'
|
||
|
||
const out = path.resolve(process.argv[2] ?? 'docs/sw/firefox-range-qa')
|
||
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
|
||
const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox/firefox.exe'
|
||
// The Firefox driver talks WebDriver BiDi over the global WebSocket (Node 22+; Node 20: --experimental-websocket).
|
||
if (typeof WebSocket === 'undefined') throw new Error('No global WebSocket: run with Node 22+ or `node --experimental-websocket` (Node 20).')
|
||
const { chromium } = loadPlaywright()
|
||
const STORY = `${base}/iframe.html?id=sw-header--overview&viewMode=story&verification`
|
||
const VIEW = { width: 1400, height: 1000 }
|
||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||
await mkdir(out, { recursive: true })
|
||
|
||
// Page-side helpers shared by both browsers (strings evaluated in the page).
|
||
const GAUGE = `document.querySelector('.hm-sw-property [data-property-id="opacity"] input[type="range"]')`
|
||
const VALUE_INPUT = `document.querySelector('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input')`
|
||
const SETUP = `(async () => {
|
||
const wait = async test => { for (let i = 0; i < 40 && !test(); i++) await new Promise(r => setTimeout(r, 50)); if (!test()) throw new Error('setup: ' + test) }
|
||
// Overview opens with 원 선택 (single circle, opacity 0.25); the property window opens from its Header button.
|
||
if (document.querySelector('[id$="-selection"]')?.value !== 'circle') throw new Error('setup: Overview selection is not 원 선택')
|
||
const trigger = document.querySelector('.hm-sw-header [data-property-panel-trigger]')
|
||
if (!trigger?.getClientRects().length) throw new Error('setup: property window button not visible (moved into More?)')
|
||
trigger.click()
|
||
await wait(() => ${GAUGE})
|
||
${GAUGE}.scrollIntoView({ block: 'center' })
|
||
await document.fonts.ready
|
||
// Screenshots compare the gauge itself: headless Firefox driven over BiDi never reports keyboard modality
|
||
// (:focus-visible stays false even for plain buttons), so the shared focus ring is hidden in both browsers.
|
||
const st = document.createElement('style'); st.textContent = '.hm-sw-property input[type=range]{outline:none!important}'; document.head.append(st)
|
||
return 'ok' })()`
|
||
const STATE = `(() => { const r = ${GAUGE}, b = r.getBoundingClientRect()
|
||
const n = ${VALUE_INPUT}
|
||
return JSON.stringify({ value: r.value, fill: r.style.getPropertyValue('--hm-sw-range-fill'), disabled: r.disabled, number: n.value, x: b.x, y: b.y, w: b.width, h: b.height,
|
||
log: document.querySelector('[data-preview-callback]').textContent.slice(-80), focused: document.activeElement === r, focusVisible: r.matches(':focus-visible'), outline: getComputedStyle(r).outlineStyle + ' ' + getComputedStyle(r).outlineColor }) })()`
|
||
// Keyboard focus (same :focus-visible rule in both engines): focus the value input, then a real Tab.
|
||
const FOCUS_PREV = `(() => { ${VALUE_INPUT}.focus(); return 'ok' })()`
|
||
// Disabled / readOnly steps: drop the current focus, then focus the gauge itself (a disabled gauge refuses it).
|
||
const FOCUS_GAUGE = `(() => { document.activeElement?.blur?.(); ${GAUGE}.focus(); return String(document.activeElement === ${GAUGE}) })()`
|
||
// Overview "시연 데이터" checkboxes (속성 ReadOnly / 속성 Disabled): the host switches for every property.
|
||
const TOGGLE = t => `(() => { [...document.querySelectorAll('.sw-overview-data label')].find(l => l.textContent.trim() === ${JSON.stringify(t)}).querySelector('input').click(); return 'ok' })()`
|
||
// Host value through the row's value input (Overview has no external opacity button): select, type, Enter.
|
||
// The input re-renders once focused (editing look), so select after that render.
|
||
const SELECT_VALUE = `(async () => { const n = ${VALUE_INPUT}; n.focus(); await new Promise(r => setTimeout(r, 80)); n.select(); return 'ok' })()`
|
||
// Overview places the panel at fractional, engine-dependent positions (16:9 stage, panel left from the Header
|
||
// button, stage scroll on focus). Shift the panel to whole pixels before each capture, the same in both engines,
|
||
// so the pixel comparison measures the gauge drawing and not a sub-pixel phase. Test only; no Story change.
|
||
const ALIGN = `(() => { const panel = ${GAUGE}.closest('.hm-sw-property'); panel.style.translate = ''
|
||
const b = ${GAUGE}.getBoundingClientRect(); panel.style.translate = (Math.round(b.x) - b.x) + 'px ' + (Math.round(b.y) - b.y) + 'px'; return 'ok' })()`
|
||
|
||
// ---------- drivers ----------
|
||
async function chromeDriver() {
|
||
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||
const page = await browser.newPage({ viewport: VIEW, deviceScaleFactor: 1 })
|
||
const errors = []
|
||
page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
|
||
const keyMap = { ArrowRight: 'ArrowRight', Home: 'Home', End: 'End', Enter: 'Enter', Tab: 'Tab' }
|
||
return {
|
||
name: 'chrome', version: browser.version(), errors,
|
||
goto: async url => { await page.goto(url); await page.locator('.hm-sw-header').waitFor() },
|
||
eval: async expr => page.evaluate(expr),
|
||
key: async (k, n = 1) => { for (let i = 0; i < n; i++) await page.keyboard.press(keyMap[k]) },
|
||
type: async text => page.keyboard.type(text),
|
||
shot: async clip => (await page.screenshot({ clip })).toString('base64'),
|
||
close: () => browser.close(),
|
||
}
|
||
}
|
||
|
||
async function firefoxDriver() {
|
||
const profile = path.join(os.tmpdir(), `sw-ff-profile-${process.pid}`)
|
||
await mkdir(profile, { recursive: true })
|
||
const port = 9300 + (process.pid % 500)
|
||
const proc = spawn(FIREFOX, ['--headless', '--no-remote', '--profile', profile, `--remote-debugging-port=${port}`], { stdio: 'ignore', env: { ...process.env, MOZ_HEADLESS_WIDTH: String(VIEW.width), MOZ_HEADLESS_HEIGHT: String(VIEW.height) } })
|
||
let ws
|
||
for (let i = 0; i < 60 && !ws; i++) {
|
||
await sleep(500)
|
||
try { ws = await new Promise((res, rej) => { const s = new WebSocket(`ws://127.0.0.1:${port}/session`); s.onopen = () => res(s); s.onerror = rej }) } catch { ws = undefined }
|
||
}
|
||
if (!ws) throw new Error('Firefox BiDi not reachable')
|
||
let id = 0; const pending = new Map(), errors = []
|
||
ws.onmessage = ev => {
|
||
const m = JSON.parse(ev.data)
|
||
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.type === 'error' ? rej(new Error(`${m.error}: ${m.message}`)) : res(m.result) }
|
||
else if (m.method === 'log.entryAdded' && m.params.level === 'error') errors.push(m.params.text)
|
||
}
|
||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej: e => rej(new Error(method + " -> " + e.message)) }); ws.send(JSON.stringify({ id: i, method, params })) })
|
||
const session = await send('session.new', { capabilities: {} })
|
||
await send('session.subscribe', { events: ['log.entryAdded'] })
|
||
const tree = await send('browsingContext.getTree', {})
|
||
const context = tree.contexts[0].context
|
||
// setViewport needs system access in Firefox; the headless window size above gives the same 1400 x 1000.
|
||
const KEYS = { ArrowRight: '\uE014', Home: '\uE011', End: '\uE010', Enter: '\uE007', Tab: '\uE004' }
|
||
return {
|
||
name: 'firefox', version: session.capabilities.browserVersion, errors,
|
||
goto: async url => {
|
||
await send('browsingContext.navigate', { context, url, wait: 'complete' })
|
||
for (let i = 0; i < 40; i++) { const r = await send('script.evaluate', { expression: `!!document.querySelector('.hm-sw-header')`, target: { context }, awaitPromise: false }); if (r.result.value) return; await sleep(250) }
|
||
throw new Error('story did not render in Firefox')
|
||
},
|
||
eval: async expr => { const r = await send('script.evaluate', { expression: expr, target: { context }, awaitPromise: true }); if (r.type === 'exception') throw new Error(r.exceptionDetails.text); return r.result.value },
|
||
key: async (k, n = 1) => { const a = []; for (let i = 0; i < n; i++) a.push({ type: 'keyDown', value: KEYS[k] }, { type: 'keyUp', value: KEYS[k] }); await send('input.performActions', { context, actions: [{ type: 'key', id: 'kb', actions: a }] }) },
|
||
type: async text => { const a = [...text].flatMap(c => [{ type: 'keyDown', value: c }, { type: 'keyUp', value: c }]); await send('input.performActions', { context, actions: [{ type: 'key', id: 'kb', actions: a }] }) },
|
||
shot: async clip => (await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...clip } })).data,
|
||
close: async () => { try { await send('session.end', {}) } catch {} ws.close(); proc.kill(); await sleep(500); await rm(profile, { recursive: true, force: true }).catch(() => {}) },
|
||
}
|
||
}
|
||
|
||
// ---------- scenario ----------
|
||
const STEPS = [
|
||
{ name: 'initial', run: async d => {} },
|
||
{ name: 'keyboard-0.65', run: async d => { await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('ArrowRight', 40) } },
|
||
{ name: 'enter-commit', run: async d => { await d.key('Enter') } },
|
||
{ name: 'max', run: async d => { await d.key('End') } },
|
||
{ name: 'min', run: async d => { await d.key('Home') } },
|
||
// Ends with no focus, like the former external button (the value comes from the host, not from the gauge).
|
||
{ name: 'host-0.8', run: async d => { await d.eval(SELECT_VALUE); await d.type('0.8'); await d.key('Enter'); await sleep(100); await d.eval(`(() => { document.activeElement?.blur?.(); return 'ok' })()`) } },
|
||
{ name: 'disabled', run: async d => { await d.eval(TOGGLE('속성 Disabled')); await sleep(100); d.gaugeFocus = await d.eval(FOCUS_GAUGE); await d.key('ArrowRight', 5) } },
|
||
{ name: 'readonly', run: async d => { await d.eval(TOGGLE('속성 Disabled')); await d.eval(TOGGLE('속성 ReadOnly')); await sleep(100); d.gaugeFocus = await d.eval(FOCUS_GAUGE); await d.key('ArrowRight', 5) } },
|
||
{ name: 'restored', run: async d => { await d.eval(TOGGLE('속성 ReadOnly')); await sleep(100); await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('Home') } },
|
||
]
|
||
async function run(driver) {
|
||
await driver.goto(STORY)
|
||
await driver.eval(SETUP)
|
||
const states = []
|
||
for (const step of STEPS) {
|
||
driver.gaugeFocus = undefined
|
||
await step.run(driver); await sleep(250)
|
||
await driver.eval(ALIGN); await sleep(50)
|
||
const st ={ ...JSON.parse(await driver.eval(STATE)), gaugeFocusTaken: driver.gaugeFocus }
|
||
// Clip at whole pixels around the input (range is 86 x 16): 2px margin each side.
|
||
const clip = { x: Math.round(st.x) - 2, y: Math.round(st.y) - 2, width: 90, height: 20 }
|
||
const png = await driver.shot(clip)
|
||
await writeFile(path.join(out, `${driver.name}-${step.name}.png`), Buffer.from(png, 'base64'))
|
||
states.push({ step: step.name, ...st, offset: { x: st.x - clip.x, y: st.y - clip.y }, png })
|
||
}
|
||
return states
|
||
}
|
||
|
||
const report = { story: STORY, viewport: VIEW, browsers: {}, steps: [], checks: [] }
|
||
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
|
||
const results = {}
|
||
for (const make of [chromeDriver, firefoxDriver]) {
|
||
const d = await make()
|
||
try { results[d.name] = await run(d); report.browsers[d.name] = { version: d.version, errors: d.errors } } finally { await d.close() }
|
||
}
|
||
|
||
// ---------- analysis (pixels decoded in a Chrome page) ----------
|
||
const ab = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||
const ap = await ab.newPage()
|
||
const analyse = (png, ox, oy) => ap.evaluate(async ([png, ox, oy]) => {
|
||
const i = new Image(); i.src = 'data:image/png;base64,' + png; await i.decode()
|
||
const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0)
|
||
const d = x.getImageData(0, 0, i.width, i.height).data, px = (cx, cy) => { const k = (cy * i.width + cx) * 4; return [d[k], d[k + 1], d[k + 2]] }
|
||
const bg = px(0, 0), same = (a, b, t = 6) => a.every((v, k) => Math.abs(v - b[k]) <= t)
|
||
const top = Math.round(oy), left = Math.round(ox)
|
||
// Thumb bbox: red-dominant pixels outside the 6 track rows (rows 5-10 of the 16px input).
|
||
let tx0 = 1e9, tx1 = -1, ty0 = 1e9, ty1 = -1
|
||
for (let yy = 0; yy < i.height; yy++) { const r = yy - top; if (r >= 5 && r <= 10) continue
|
||
for (let xx = 0; xx < i.width; xx++) { const p = px(xx, yy); if (p[0] > 90 && p[0] - p[1] > 50 && p[0] - p[2] > 50) { tx0 = Math.min(tx0, xx); tx1 = Math.max(tx1, xx); ty0 = Math.min(ty0, yy); ty1 = Math.max(ty1, yy) } } }
|
||
const thumb = tx1 < 0 ? null : { left: tx0 - left, right: tx1 - left, top: ty0 - top, bottom: ty1 - top, cx: (tx0 + tx1 + 1) / 2 - left, cy: (ty0 + ty1 + 1) / 2 - top }
|
||
// Track column profiles: fill column (x=3 of input) and beige column (x=82 of input).
|
||
const column = cx => { const rows = []; for (let r = 3; r <= 12; r++) rows.push(px(left + cx, top + r)); return rows }
|
||
const extent = col => { const idx = col.map((p, k) => same(p, bg, 10) ? -1 : k + 3).filter(k => k >= 0); return idx.length ? { from: idx[0], to: idx.at(-1), rows: idx.length } : null }
|
||
const hex = p => p.map(v => v.toString(16).padStart(2, '0')).join('')
|
||
const fillCol = column(3), trackCol = column(82)
|
||
return { thumb, fillCol: fillCol.map(hex), trackCol: trackCol.map(hex), fillExtent: extent(fillCol), trackExtent: extent(trackCol) }
|
||
}, [png, ox, oy])
|
||
const diff = (a, b) => ap.evaluate(async ([a, b]) => {
|
||
const load = async s => { const i = new Image(); i.src = 'data:image/png;base64,' + s; await i.decode(); const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0); return x.getImageData(0, 0, i.width, i.height).data }
|
||
const A = await load(a), B = await load(b); let max = 0, over16 = 0, over48 = 0, sum = 0
|
||
for (let k = 0; k < A.length; k += 4) { const m = Math.max(Math.abs(A[k] - B[k]), Math.abs(A[k + 1] - B[k + 1]), Math.abs(A[k + 2] - B[k + 2])); max = Math.max(max, m); sum += m; if (m > 16) over16++; if (m > 48) over48++ }
|
||
return { pixels: A.length / 4, max, mean: +(sum / (A.length / 4)).toFixed(2), over16, over48 }
|
||
}, [a, b])
|
||
|
||
for (let k = 0; k < STEPS.length; k++) {
|
||
const c = results.chrome[k], f = results.firefox[k]
|
||
const ca = await analyse(c.png, c.offset.x, c.offset.y), fa = await analyse(f.png, f.offset.x, f.offset.y)
|
||
const dd = await diff(c.png, f.png)
|
||
const strip = s => { const { png, ...rest } = s; return rest }
|
||
report.steps.push({ step: STEPS[k].name, chrome: { ...strip(c), pixels: ca }, firefox: { ...strip(f), pixels: fa }, diff: dd })
|
||
}
|
||
await ab.close()
|
||
|
||
// ---------- checks ----------
|
||
const S = Object.fromEntries(report.steps.map(s => [s.step, s]))
|
||
const isRed = h => { const v = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); return v[0] > 100 && v[0] - v[1] > 60 }
|
||
const isBeige = h => { const v = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); return v[0] > 160 && v[1] > 150 && v[2] > 120 && v[0] - v[2] > 15 && v[0] - v[2] < 45 }
|
||
for (const b of ['chrome', 'firefox']) {
|
||
const st = n => S[n][b]
|
||
check(`${b} 1-3 red fill / beige track / black-edged 6px`, isRed(st('initial').pixels.fillCol[4]) && isBeige(st('initial').pixels.trackCol[4]) && st('initial').pixels.fillCol[2] === '000000' && st('initial').pixels.fillCol[7] === '000000' && st('initial').pixels.fillExtent.from === 5 && st('initial').pixels.fillExtent.to >= 10, { fill: st('initial').pixels.fillCol, track: st('initial').pixels.trackCol, extent: st('initial').pixels.fillExtent })
|
||
const t = st('initial').pixels.thumb
|
||
check(`${b} 4-5 thumb 14px, vertically centred on the track`, t && t.right - t.left + 1 >= 11 && t.right - t.left + 1 <= 15 && Math.abs(t.cy - 8) <= 0.5, t)
|
||
for (const [n, v] of [['min', 0], ['initial', 0.25], ['keyboard-0.65', 0.65], ['max', 1], ['host-0.8', 0.8]]) {
|
||
const s = st(n), expect = 7 + 72 * v
|
||
check(`${b} 6-8/10 ${n}: value ${s.value}, thumb centre ${s.pixels.thumb?.cx} ≈ ${expect}`, Number(s.value) === v && Number(s.fill) === v && Math.abs(s.pixels.thumb.cx - expect) <= 1, { value: s.value, fill: s.fill, cx: s.pixels.thumb?.cx })
|
||
}
|
||
check(`${b} 9 keyboard + Enter commit`, st('keyboard-0.65').value === '0.65' && st('enter-commit').number === '0.65' && st('enter-commit').log.includes('onPropertyChange(opacity, 0.65)'), { value: st('keyboard-0.65').value, number: st('enter-commit').number, log: st('enter-commit').log })
|
||
check(`${b} 10b host value (value input commit) moves the gauge`, st('host-0.8').number === '0.8' && st('host-0.8').log.includes('onPropertyChange(opacity, 0.8)'), { number: st('host-0.8').number, log: st('host-0.8').log })
|
||
check(`${b} 11 disabled / readOnly block focus and keys, keep design`, st('disabled').disabled && st('disabled').value === '0.8' && st('disabled').gaugeFocusTaken === 'false' && st('readonly').disabled && st('readonly').value === '0.8' && st('readonly').gaugeFocusTaken === 'false' && isRed(st('disabled').pixels.fillCol[4]) && isBeige(st('readonly').pixels.trackCol[4]) && !st('restored').disabled && st('restored').value === '0', { disabled: [st('disabled').disabled, st('disabled').value, st('disabled').gaugeFocusTaken], readonly: [st('readonly').disabled, st('readonly').value, st('readonly').gaugeFocusTaken], restored: [st('restored').disabled, st('restored').value] })
|
||
check(`${b} 12 no console / page errors`, report.browsers[b].errors.filter(e => !e.includes('/sb-common-assets/')).length === 0, report.browsers[b].errors)
|
||
}
|
||
const near = (a, b, t) => a.every((h, k) => [0, 2, 4].every(i => Math.abs(parseInt(h.slice(i, i + 2), 16) - parseInt(b[k].slice(i, i + 2), 16)) <= t))
|
||
const noHighlight = col => col.filter((_, k) => k !== 8) // 1px highlight row under the track, reported separately
|
||
for (const s of report.steps) {
|
||
const ct = s.chrome.pixels.thumb, ft = s.firefox.pixels.thumb
|
||
const fillSame = s.step === 'min' || s.step === 'restored' || near(noHighlight(s.chrome.pixels.fillCol), noHighlight(s.firefox.pixels.fillCol), 3)
|
||
check(`compare ${s.step}: columns ±3, thumb ±1px, ≤12 px differ > 48`,
|
||
fillSame && near(noHighlight(s.chrome.pixels.trackCol), noHighlight(s.firefox.pixels.trackCol), 3)
|
||
&& Math.abs(ct.cx - ft.cx) <= 1 && Math.abs(ct.cy - ft.cy) <= 1 && s.diff.over48 <= 12,
|
||
{ diff: s.diff, focus: [s.chrome.focusVisible, s.firefox.focusVisible], chromeThumb: ct, firefoxThumb: ft, highlight: [s.chrome.pixels.fillCol[8], s.firefox.pixels.fillCol[8]] })
|
||
}
|
||
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2))
|
||
console.log('BROWSERS', JSON.stringify(Object.fromEntries(Object.entries(report.browsers).map(([k, v]) => [k, v.version]))))
|
||
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)
|
||
if (report.checks.some(c => c.status === 'FAIL')) process.exitCode = 1
|