Add initial fit and direct wheel zoom with preserved default presentation. Integrate five scoped GuardFix findings and AI generation preselection policy. Exclude Cell experiments and retain five historical guard failures.
155 lines
10 KiB
JavaScript
155 lines
10 KiB
JavaScript
// 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;
|