// Storybook regression for the Chart Only change. Compares this worktree's // Storybook (STORYBOOK_URL) with a baseline Storybook (BASELINE_URL, optional) // and checks that Expanded keeps its controls and Ctrl/⌘ + wheel policy. import assert from 'node:assert/strict'; import path from 'node:path'; import { mkdir, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-chart-only/storybook'); await mkdir(output, { recursive: true }); const target = process.env.STORYBOOK_URL ?? 'http://127.0.0.1:6044'; const baseline = process.env.BASELINE_URL; const browser = await launchChromium(); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 }); const results = []; const consoleErrors = []; const wheelOnly = process.argv.includes('--wheel-only'); const record = (name, pass, detail) => { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}`, detail === undefined ? '' : JSON.stringify(detail)); }; const check = async (name, fn) => { try { record(name, true, await fn()); } catch (error) { record(name, false, error.message); } }; const stories = ['orgchart--logo-personnel-summary', 'orgchart--department-colors', 'orgchart--full-composition', 'orgchart--expanded-org-chart']; async function open(origin, id) { const page = await context.newPage(); page.on('console', message => { if (message.type() === 'error') consoleErrors.push({ origin, id, text: message.text() }); }); page.on('pageerror', error => consoleErrors.push({ origin, id, text: String(error) })); await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`); await page.waitForFunction(() => { const hosts = [...document.querySelectorAll('.hm-orgchart-host')]; // Overview renders summaries without an OrgChart host. return document.getElementById('storybook-root')?.childElementCount && hosts.every(host => host.shadowRoot?.querySelector('.hm-orgchart-shell')?.getAttribute('aria-busy') === 'false'); }, null, { timeout: 60000 }); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(800); return page; } /** Story DOM plus every OrgChart shadow root, without instance/React IDs. */ const snapshot = page => page.evaluate(() => [document.getElementById('storybook-root').innerHTML, ...[...document.querySelectorAll('.hm-orgchart-host')].map(host => host.shadowRoot.innerHTML)].join('\n') .replace(/hm-orgchart-\d+/g, 'hm-orgchart-N').replace(/«[^»]*»|_r_[0-9a-z]+_/g, 'react-id')); async function pixelDiff(a, b) { const page = await context.newPage(); const result = await page.evaluate(async ([ua, ub]) => { const load = src => new Promise(resolve => { const image = new Image(); image.onload = () => resolve(image); image.src = src; }); const [ia, ib] = await Promise.all([load(ua), load(ub)]); if (ia.width !== ib.width || ia.height !== ib.height) return { size: [ia.width, ia.height, ib.width, ib.height], diffPixels: Infinity }; const read = image => { const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; const c = canvas.getContext('2d'); c.drawImage(image, 0, 0); return c.getImageData(0, 0, image.width, image.height).data; }; const da = read(ia), db = read(ib); let diffPixels = 0, maxDelta = 0; const box = [Infinity, Infinity, -1, -1]; for (let i = 0; i < da.length; i += 4) { const delta = Math.max(Math.abs(da[i] - db[i]), Math.abs(da[i + 1] - db[i + 1]), Math.abs(da[i + 2] - db[i + 2])); if (!delta) continue; diffPixels++; maxDelta = Math.max(maxDelta, delta); const p = i / 4, x = p % ia.width, y = Math.floor(p / ia.width); box[0] = Math.min(box[0], x); box[1] = Math.min(box[1], y); box[2] = Math.max(box[2], x); box[3] = Math.max(box[3], y); } return { size: [ia.width, ia.height], diffPixels, maxDelta, box }; }, [`data:image/png;base64,${a.toString('base64')}`, `data:image/png;base64,${b.toString('base64')}`]); await page.close(); return result; } const index = await (await fetch(`${target}/index.json`)).json(); if (!wheelOnly) await check('Storybook OrgChart public menus', () => { const visible = Object.values(index.entries).filter(entry => entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal')).map(entry => `${entry.title} › ${entry.name}`); assert.deepEqual(visible.sort(), ['OrgChart/Department Colors › Department Colors', 'OrgChart/Expanded OrgChart › Expanded OrgChart', 'OrgChart/Full Composition › Full Composition', 'OrgChart/Overview › Overview']); return visible; }); for (const id of wheelOnly ? ['orgchart--expanded-org-chart'] : stories) { const page = await open(target, id); const buffer = await page.screenshot({ path: path.join(output, `${id}.png`), fullPage: true }); const dom = await snapshot(page); if (baseline) { const reference = await open(baseline, id); const referenceBuffer = await reference.screenshot({ path: path.join(output, `${id}.baseline.png`), fullPage: true }); const referenceDom = await snapshot(reference); // Pixel identity is reported, not assumed: when it differs, a second // baseline capture shows whether the baseline itself is deterministic. const pixels = buffer.equals(referenceBuffer) ? { identical: true } : { target: await pixelDiff(buffer, referenceBuffer), baselineSelf: await pixelDiff(referenceBuffer, await (await open(baseline, id)).screenshot({ fullPage: true })), }; await check(`${id} matches baseline (DOM + pixels)`, () => { assert.equal(dom, referenceDom, 'shadow DOM differs'); // Identical DOM with a handful of ±1 channel pixels is rasterization // jitter of the scaled Fixed stage, not a design change. if (!pixels.identical) assert.ok(pixels.target.diffPixels <= 50 && pixels.target.maxDelta <= 2, `pixel diff ${JSON.stringify(pixels)}`); return { domLength: dom.length, pixels }; }); await reference.close(); } if (id === 'orgchart--expanded-org-chart') { const state = () => page.evaluate(() => { const sr = document.querySelector('.hm-orgchart-host').shadowRoot; const vp = sr.querySelector('.hm-orgchart-viewport'); return { scale: +vp.dataset.scale, x: +vp.dataset.panX, y: +vp.dataset.panY, heading: sr.querySelector('.hm-orgchart-title')?.textContent, date: sr.querySelector('.hm-orgchart-date')?.textContent, selection: sr.querySelector('.hm-orgchart-expanded-selection')?.textContent, toolbar: [...sr.querySelectorAll('.hm-orgchart-expanded-toolbar > *')].map(e => e.textContent), feedback: sr.querySelector('.hm-orgchart-move-feedback')?.textContent, presentation: sr.querySelector('.hm-orgchart-shell').dataset.presentationMode ?? null }; }); const before = await state(); if (!wheelOnly) await check('Expanded keeps heading, date, summary, toolbar and guidance', () => { assert.equal(before.presentation, null); assert.ok(before.heading && before.date?.startsWith('기준일') && before.selection?.startsWith('선택')); assert.deepEqual(before.toolbar.slice(0, 4), ['확대', '축소', '100% 복원', 'Fit to View']); assert.match(before.toolbar[4], /^\d+%$/); assert.equal(before.toolbar[5], '드래그·방향키 이동 · Ctrl/⌘ + 휠 확대'); assert.match(before.feedback, /Drag & Drop OFF/); return before; }); const canvas = page.locator('.hm-orgchart-viewport'); await canvas.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' })); const box = await canvas.boundingBox(); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.wheel(0, -100); await page.waitForTimeout(150); const plain = await state(); const plainScroll = await page.evaluate(() => scrollY); // A plain wheel scrolls the story page; aim at the canvas again. await canvas.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' })); const moved = await canvas.boundingBox(); await page.mouse.move(moved.x + moved.width / 2, moved.y + moved.height / 2); await page.keyboard.down('Control'); await page.mouse.wheel(0, -100); await page.keyboard.up('Control'); await page.waitForTimeout(150); const ctrl = await state(); await check('Expanded plain wheel does not zoom; Ctrl + wheel zooms', () => { assert.equal(plain.scale, before.scale); assert.ok(ctrl.scale > plain.scale); return { before: before.scale, plain: plain.scale, ctrl: +ctrl.scale.toFixed(4), pageScrolledByPlainWheel: plainScroll }; }); if (!wheelOnly) { await page.getByRole('button', { name: 'Fit to View' }).click(); const fitted = await state(); await check('Expanded Fit to View button still works', () => { assert.ok(fitted.scale <= 1 && fitted.scale !== ctrl.scale); return { scale: +fitted.scale.toFixed(4) }; }); } } if (id === 'orgchart--full-composition') { await check('Full Composition keeps Fixed composition', () => page.evaluate(() => { const shells = [...document.querySelectorAll('.hm-orgchart-host')].map(host => host.shadowRoot.querySelector('.hm-orgchart-shell')); if (!shells.length || shells.some(shell => shell.dataset.presentationMode)) throw new Error('unexpected presentation mode'); return shells.map(shell => ({ layout: shell.dataset.layoutMode, heading: !!shell.querySelector('.hm-orgchart-heading'), stage: !!shell.querySelector('.hm-orgchart-stage') })); })); } await page.close(); } await browser.close(); const failed = results.filter(result => !result.pass); await writeFile(path.join(output, wheelOnly ? 'results-wheel.json' : 'results.json'), JSON.stringify({ target, baseline, results, consoleErrors }, null, 2)); console.log(`\n${results.length - failed.length}/${results.length} PASS · console errors ${consoleErrors.length}`); for (const error of consoleErrors) console.log('console:', error.origin, error.id, error.text); if (failed.length || consoleErrors.length) process.exitCode = 1;