Files
HMDesign/examples/org-chart-react/verify.mjs
T

313 lines
27 KiB
JavaScript

import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
import assert from 'node:assert/strict';
import http from 'node:http';
import path from 'node:path';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-react');
await mkdir(output, { recursive: true });
await loadPlaywright();
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf', '.woff2': 'font/woff2', '.svg': 'image/svg+xml' };
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(directory, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; }
response.setHeader('Content-Type', mime[path.extname(file)] || 'application/octet-stream');
response.end(await readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
return { server, origin: `http://127.0.0.1:${server.address().port}` };
}
const consumer = await serve(path.join(root, 'examples/org-chart-react/dist'));
const vanilla = await serve(path.join(root, 'examples/org-chart/dist'));
const storybook = await serve(path.join(root, 'storybook-static'));
const browser = await launchChromium({ headless: true });
const results = [], errors = [], metrics = [], snapshots = {};
function check(condition, name, detail) {
results.push({ name, status: condition ? 'PASS' : 'FAIL', detail });
assert.ok(condition, `${name}: ${JSON.stringify(detail)}`);
}
function captureErrors(page, transport) {
page.on('pageerror', error => errors.push({ transport, type: 'pageerror', message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ transport, type: 'console', message: message.text() }); });
}
async function snapshot(page, selector = '.hm-orgchart-host') {
return page.locator(selector).first().evaluate(host => {
const shadow = host.shadowRoot;
const nodes = [...shadow.querySelectorAll('.node')];
return {
organizations: nodes.map(node => ({
id: node.dataset.organizationId,
parentId: node.parentElement.parentElement.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId || null,
employees: [...node.querySelectorAll('.hm-orgchart-employee')].map(employee => employee.dataset.employeeId),
text: node.querySelector('.hm-orgchart-organization').textContent,
})),
summary: shadow.querySelector('.hm-orgchart-summary')?.textContent,
heading: shadow.querySelector('.hm-orgchart-heading')?.textContent,
css: [...shadow.querySelectorAll('link')].map(link => ({ href: link.href, loaded: !!link.sheet })),
style: (() => { const s = getComputedStyle(shadow.querySelector('.hm-orgchart-organization')); return { color: s.color, background: s.backgroundColor, font: s.fontFamily, radius: s.borderRadius }; })(),
};
});
}
async function ready(page, instance = 'A', version) {
await page.waitForFunction(({ instance, version }) => {
const event = window.OrgChartReactDemo.events.filter(event => event.instance === instance).at(-1);
const host = document.querySelector(`[data-instance="${instance}"] .hm-orgchart-host`);
return !!host?.shadowRoot.querySelector('.hm-orgchart-shell[aria-busy="false"] .node') && event?.state.status === 'ready' && (!version || event.state.dataVersion === version);
}, { instance, version });
}
async function patch(page, value) { await page.evaluate(value => window.OrgChartReactDemo.render(value), value); }
async function source(page, name, instance = 'A') {
await page.evaluate(({ name, instance }) => window.OrgChartReactDemo.render({ [`source${instance}`]: window.OrgChartReactDemo.sources[name] }), { name, instance });
}
try {
const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
await context.addInitScript(() => {
const NativeObserver = window.MutationObserver;
const observers = new Map();
window.MutationObserver = class extends NativeObserver {
observe(...args) { observers.set(this, args[0]); return super.observe(...args); }
disconnect() { observers.delete(this); return super.disconnect(); }
};
window.chartObservers = () => [...observers.values()].filter(target => target.getRootNode()?.host?.classList.contains('hm-orgchart-host')).length;
});
const html = await context.newPage();
captureErrors(html, 'vanilla-http');
await html.goto(`${vanilla.origin}/index.html`);
await html.waitForFunction(() => window.orgChartReady);
snapshots.htmlV1 = await snapshot(html);
const page = await context.newPage();
captureErrors(page, 'react-production');
if (process.env.ORGCHART_CONSUMER_URL) {
const production = await context.newPage(); captureErrors(production, 'react-production');
await production.goto(consumer.origin); await ready(production, 'A', 'mock-1');
snapshots.reactProduction = await snapshot(production);
check(JSON.stringify(snapshots.reactProduction.organizations) === JSON.stringify(snapshots.htmlV1.organizations) && snapshots.reactProduction.css.every(link => link.loaded), 'independent production React Consumer: public imports, assets and identical HTML nodes');
await production.close();
}
await page.goto(process.env.ORGCHART_CONSUMER_URL || consumer.origin);
await ready(page, 'A', 'mock-1');
snapshots.reactV1 = await snapshot(page);
check(JSON.stringify(snapshots.reactV1.organizations) === JSON.stringify(snapshots.htmlV1.organizations), 'HTML/React v1 hierarchy, employee placement and identical node UI', snapshots.reactV1.organizations);
check(snapshots.reactV1.summary === snapshots.htmlV1.summary && snapshots.reactV1.heading === snapshots.htmlV1.heading, 'HTML/React v1 identical summary and presentation');
check(JSON.stringify(snapshots.reactV1.style) === JSON.stringify(snapshots.htmlV1.style), 'HTML/React identical computed theme', snapshots.reactV1.style);
check(snapshots.reactV1.css.length === 2 && snapshots.reactV1.css.every(link => link.loaded), 'production Shadow DOM CSS assets loaded', snapshots.reactV1.css);
const initial = await page.evaluate(() => ({ events: window.OrgChartReactDemo.events, requests: window.OrgChartReactDemo.requests, observers: window.chartObservers() }));
const expectedLoads = process.env.ORGCHART_CONSUMER_URL ? 2 : 1;
check(initial.requests.v1.loads === expectedLoads && initial.events.filter(event => event.state.status === 'ready').length === 1, 'Strict Mode: expected initial load count and single ready (development replay; production single setup)', initial);
check(await page.locator('.hm-orgchart-host').count() === 1, 'Strict Mode: exactly one active Core host');
const hostId = await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance');
await patch(page, { callback: 2, className: 'consumer-chart changed' });
await page.waitForFunction(() => document.querySelector('.consumer-chart.changed'));
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId && (await page.evaluate(() => window.OrgChartReactDemo.requests.v1.loads)) === expectedLoads, 'callback/className-only render retains Core and does not reload');
await source(page, 'v2');
await ready(page, 'A', 'mock-2');
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId, 'source identity uses reload on the same Core host');
const stateV2 = await page.evaluate(() => window.OrgChartReactDemo.events.at(-1));
check(stateV2.callback === 2 && stateV2.state.summary.raw.total === 5 && stateV2.state.summary.raw.byOrganization['team-a'].direct === 1 && stateV2.state.summary.raw.byOrganization['team-b'].direct === 3, 'latest callback and v2 expected 1/1/3 placement, total 5', stateV2);
await html.evaluate(() => window.OrgChartDemo.update());
snapshots.htmlV2 = await snapshot(html);
snapshots.reactV2 = await snapshot(page);
check(JSON.stringify(snapshots.reactV2.organizations) === JSON.stringify(snapshots.htmlV2.organizations) && snapshots.reactV2.summary === snapshots.htmlV2.summary, 'HTML/React v2 data, hierarchy, employee nodes and aggregation parity');
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1100 });
await page.evaluate(() => document.fonts.ready);
const metric = await page.locator('.hm-orgchart-host').evaluate(host => {
const shadow = host.shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport');
const rectangles = [...shadow.querySelectorAll('.node')].map(node => node.getBoundingClientRect());
const overlap = rectangles.some((a, index) => rectangles.slice(index + 1).some(b => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1));
const organization = shadow.querySelector('.hm-orgchart-organization'), style = getComputedStyle(organization);
const probe = document.createElement('div');
probe.style.color = 'var(--hm-sem-fg-primary)'; probe.style.background = 'var(--hm-sem-bg-surface)'; probe.style.borderRadius = 'var(--hm-radius-container)'; shadow.append(probe);
const expectedStyle = getComputedStyle(probe), expected = { color: expectedStyle.color, background: expectedStyle.backgroundColor, radius: expectedStyle.borderRadius };
const match = expected.color === style.color && expected.background === style.backgroundColor && expected.radius === style.borderRadius;
probe.remove();
const nodes = [...shadow.querySelectorAll('.node')], rect = nodes[0].getBoundingClientRect();
const centers = nodes.slice(1).map(node => { const r = node.getBoundingClientRect(); return r.x + r.width / 2; });
return {
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
host: { clientWidth: host.clientWidth, scrollWidth: host.scrollWidth, clientHeight: host.clientHeight, scrollHeight: host.scrollHeight },
viewport: { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX },
overlap, themeMatch: match, expected, color: style.color, background: style.backgroundColor, radius: style.borderRadius, font: style.fontFamily,
fontLoaded: document.fonts.check('13px "Noto Sans KR"'), idWhiteSpace: getComputedStyle(shadow.querySelector('.hm-orgchart-domain-id')).whiteSpace,
connectorsAligned: Math.abs(rect.x + rect.width / 2 - (centers[0] + centers[1]) / 2) < 2,
connectorTheme: getComputedStyle(nodes[1], '::before').backgroundColor === getComputedStyle(viewport).borderTopColor,
};
});
metrics.push({ width, ...metric });
check(metric.document.scrollWidth <= width && metric.host.scrollWidth <= metric.host.clientWidth && metric.viewport.overflowX === 'auto' && !metric.overlap, `${width}px container overflow/scroll/no overlap`, metric);
check(metric.themeMatch && metric.font.includes('Noto Sans KR') && metric.fontLoaded && metric.idWhiteSpace === 'nowrap' && metric.connectorsAligned && metric.connectorTheme, `${width}px theme/fonts/IDs/connectors`, metric);
await page.screenshot({ path: path.join(output, `react-consumer-${width}.png`), fullPage: true });
}
const toggle = page.locator('.hm-orgchart-toggle').first();
await toggle.focus(); await toggle.press('Enter');
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
check(await toggle.getAttribute('aria-expanded') === 'false', 'React keyboard Enter collapses');
await toggle.press('Space');
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
check(await toggle.getAttribute('aria-expanded') === 'true', 'React keyboard Space expands');
await source(page, 'v1'); await ready(page, 'A', 'mock-1');
check(await page.locator('.hm-orgchart-viewport').evaluate(element => element.getRootNode().activeElement === element), 'source reload restores removed keyboard focus to stable viewport');
await page.evaluate(() => window.OrgChartReactDemo.render({ sourceA: window.OrgChartReactDemo.slowSource }));
await page.waitForFunction(() => window.OrgChartReactDemo.requests.slow.loads === 1);
await source(page, 'v2'); await ready(page, 'A', 'mock-2');
const beforeLate = await page.evaluate(() => window.OrgChartReactDemo.events.length);
await page.evaluate(() => window.OrgChartReactDemo.releaseSlow());
await page.waitForTimeout(100);
check(await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.dataVersion === 'mock-2' && window.OrgChartReactDemo.requests.slow.aborts === 1) && (await page.evaluate(() => window.OrgChartReactDemo.events.length)) === beforeLate, 'superseded abort-ignoring source cannot update DOM/callback');
await source(page, 'error');
await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.status === 'error');
check(await page.locator('.hm-orgchart-issues').count() === 1, 'source failure controlled error state');
await source(page, 'v1'); await ready(page, 'A', 'mock-1');
check(await page.locator('.hm-orgchart-issues').count() === 0, 'Error to Ready recovery via source replacement');
await source(page, 'zeroEmployees'); await ready(page);
check((await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.summary.raw.total)) === 0, 'zero employees remains Ready with total 0');
await source(page, 'zeroOrganizations');
await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.status === 'error');
check(await page.locator('.hm-orgchart-issues').count() === 1, 'zero organizations is Error; no fabricated empty state');
await source(page, 'v1'); await ready(page, 'A', 'mock-1');
await page.evaluate(() => window.OrgChartReactDemo.render({ aggregationPolicy: { policyVersion: 'consumer-test', includedStatuses: ['active'], activeStatuses: ['active'], includeLeaders: true, membershipMode: 'primaryOnly' } }));
await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.aggregation?.status === 'ready');
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') !== hostId && (await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.aggregation.counts.total)) === 2, 'policy change safely recreates Core and uses explicit test policy');
let currentHost = await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance');
await page.evaluate(() => window.OrgChartReactDemo.render({ stylesheetUrls: { ...window.OrgChartReactDemo.configuration.stylesheetUrls } }));
await page.waitForTimeout(70);
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === currentHost, 'equal stylesheet URL values retain Core');
await page.evaluate(() => { const urls = window.OrgChartReactDemo.configuration.stylesheetUrls; window.OrgChartReactDemo.render({ stylesheetUrls: { engine: urls.engine + '?consumer=2', core: urls.core }, aggregationPolicy: undefined }); });
await page.waitForFunction(oldId => document.querySelector('.hm-orgchart-host')?.dataset.orgchartInstance !== oldId, currentHost);
await ready(page);
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') !== currentHost && (await snapshot(page)).css.every(link => link.loaded), 'changed stylesheet URL safely recreates and loads assets');
await patch(page, { mountedB: true }); await ready(page, 'B', 'mock-2');
const bHost = await page.locator('[data-instance="B"] .hm-orgchart-host').getAttribute('data-orgchart-instance');
const bSnapshot = await snapshot(page, '[data-instance="B"] .hm-orgchart-host');
await source(page, 'v2'); await ready(page, 'A', 'mock-2');
check(await page.locator('.hm-orgchart-host').count() === 2 && JSON.stringify(await snapshot(page, '[data-instance="B"] .hm-orgchart-host')) === JSON.stringify(bSnapshot), 'two simultaneous instances; A update preserves B');
await page.evaluate(() => { document.querySelector('[data-instance="A"]').style.setProperty('--hm-sem-bg-surface', getComputedStyle(document.documentElement).getPropertyValue('--hm-sem-bg-content')); });
check((await snapshot(page, '[data-instance="A"] .hm-orgchart-host')).style.background !== bSnapshot.style.background && (await snapshot(page, '[data-instance="B"] .hm-orgchart-host')).style.background === bSnapshot.style.background, 'scoped existing Theme variable change inherits through Shadow DOM and isolates B');
await patch(page, { mountedA: false });
await page.waitForFunction(() => !document.querySelector('[data-instance="A"]'));
check(await page.locator('.hm-orgchart-host').count() === 1 && await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === bHost, 'A unmount preserves B and its Core identity');
const bObservers = await page.evaluate(() => window.chartObservers());
for (let iteration = 0; iteration < 5; iteration++) {
await patch(page, { mountedA: true }); await ready(page, 'A', 'mock-2');
await page.waitForFunction(() => document.querySelectorAll('.hm-orgchart-host').length === 2);
await patch(page, { mountedA: false });
await page.waitForFunction(() => document.querySelectorAll('.hm-orgchart-host').length === 1);
}
check(await page.evaluate(() => window.chartObservers()) === bObservers, 'five Strict Mode mount/unmount cycles preserve B observer baseline');
await patch(page, { mountedA: true }); await ready(page, 'A', 'mock-2');
await page.evaluate(() => window.OrgChartReactDemo.render({ sourceA: window.OrgChartReactDemo.slowSource }));
await page.waitForFunction(() => window.OrgChartReactDemo.requests.slow.loads === 2);
await patch(page, { mountedA: false });
await page.waitForFunction(() => !document.querySelector('[data-instance="A"]'));
const unmountEvents = await page.evaluate(() => window.OrgChartReactDemo.events.length);
await page.evaluate(() => window.OrgChartReactDemo.releaseSlow()); await page.waitForTimeout(100);
check((await page.evaluate(() => window.OrgChartReactDemo.events.length)) === unmountEvents, 'late response after unmount cannot emit callback');
check((await page.evaluate(() => window.OrgChartReactDemo.lifecycleErrors.length)) === 0, 'normal lifecycle has no rejected-use errors');
await patch(page, { mountedA: true, stylesheetUrls: { engine: '', core: '' } });
await page.waitForFunction(() => window.OrgChartReactDemo.lifecycleErrors.length > 0);
check(await page.locator('.hm-orgchart-host').count() === 0, 'invalid Core configuration is reported via lifecycle error without leaked hosts');
await page.evaluate(() => window.OrgChartReactDemo.destroy());
check(await page.locator('.hm-orgchart-host').count() === 0 && await page.evaluate(() => window.chartObservers()) === 0, 'terminal React root unmount removes all Core DOM/observers');
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
const stories = Object.values(index.entries).filter(entry => entry.title === 'OrgChart/Content' && entry.type === 'story');
check(stories.length === 8, 'production Storybook registers exactly eight OrgChart stories', stories.map(story => ({ id: story.id, name: story.name })));
const sb = await context.newPage(); captureErrors(sb, 'storybook-production');
for (const story of stories) {
await sb.goto(`${storybook.origin}/iframe.html?id=${story.id}&viewMode=story`);
await sb.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-host')].some(host => host.shadowRoot.querySelector('.hm-orgchart-organization, .hm-orgchart-issues, .hm-orgchart-state:not(:empty)')));
if (story.id.includes('loading')) {
check((await sb.locator('.hm-orgchart-state').allTextContents()).some(text => text.includes('불러오는')), `${story.name}: actual loading source`);
} else {
await sb.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-host')].every(host => host.shadowRoot.querySelector('.hm-orgchart-organization')));
check(await sb.locator('.node').count() >= 3, `${story.name}: real Core nodes rendered`);
}
if (story.id.includes('json-data-update')) {
await sb.getByRole('button', { name: 'JSON v2', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
check((await sb.locator('.hm-orgchart-raw-total').textContent()).includes('5명'), 'Storybook JSON v1 to v2 recalculates 5 employees');
await sb.getByRole('button', { name: 'Unmount', exact: true }).click();
check(await sb.locator('.hm-orgchart-host').count() === 0, 'Storybook Unmount removes Core');
await sb.getByRole('button', { name: 'Mount', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.node'));
check(await sb.locator('.hm-orgchart-host').count() === 1, 'Storybook remount creates one Core');
}
if (story.id.includes('loading')) {
await sb.getByRole('button', { name: 'Source Error', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('error'));
check(await sb.locator('.hm-orgchart-issues').count() === 1, 'Storybook source error controlled by Core');
await sb.getByRole('button', { name: 'Ready v2 / 복구', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
check(await sb.locator('.node').count() === 3, 'Storybook Error to Ready recovery');
await sb.getByRole('button', { name: '직원 0명 (Ready)', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('story-zero-employees'));
check((await sb.locator('.hm-orgchart-raw-total').textContent()).includes('0명'), 'Storybook zero employees Ready');
await sb.getByRole('button', { name: '조직 0개 (Error)', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('error'));
check(await sb.locator('.hm-orgchart-issues').count() === 1, 'Storybook zero organizations Error');
await sb.getByRole('button', { name: '느린 v1 (취소 무시)', exact: true }).click();
await sb.getByRole('button', { name: 'Ready v2 / 복구', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
const outputBefore = await sb.locator('output').textContent();
await sb.waitForTimeout(4100);
check(await sb.locator('output').textContent() === outputBefore, 'Storybook stale abort-ignoring response produces no callback');
}
if (story.id.includes('multiple-instances')) {
const b = sb.getByRole('region', { name: 'OrgChart B', exact: true });
const bIdentity = await b.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance');
const bState = await b.locator('output').textContent();
await sb.getByRole('button', { name: 'A → JSON v2', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
await sb.getByRole('button', { name: 'A Unmount', exact: true }).click();
check(await sb.locator('.hm-orgchart-host').count() === 1 && await b.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === bIdentity && await b.locator('output').textContent() === bState, 'Storybook A update/unmount leaves B identity, data and callback count unchanged');
}
if (story.id.includes('basic-organization') || story.id.includes('employee-cards') || story.id.includes('responsive-container')) {
for (const width of [390, 768, 1280, 1440]) {
await sb.setViewportSize({ width, height: 1100 });
if (story.id.includes('responsive-container')) await sb.getByRole('button', { name: `${width}px`, exact: true }).click();
const dimensions = await sb.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const nodes = [...shadow.querySelectorAll('.node')], viewport = shadow.querySelector('.hm-orgchart-viewport');
const rects = nodes.map(node => node.getBoundingClientRect());
return {
clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth,
viewport: { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight },
nodeWidths: rects.map(rect => rect.width),
overlap: rects.some((a, index) => rects.slice(index + 1).some(b => Math.min(a.right,b.right)-Math.max(a.left,b.left)>1 && Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top)>1)),
avatars: [...shadow.querySelectorAll('.hm-orgchart-avatar')].every(avatar => avatar.textContent && !avatar.querySelector('img')),
textFits: [...shadow.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-organization-name')].every(element => element.scrollWidth <= element.clientWidth),
};
});
metrics.push({ story: story.name, width, ...dimensions });
check(dimensions.scrollWidth <= width && !dimensions.overlap && dimensions.textFits && dimensions.avatars, `${story.name}: ${width}px document scroll/overlap/long text/profile fallback`, dimensions);
await sb.screenshot({ path: path.join(output, `${story.id}-${width}.png`), fullPage: true });
}
}
await sb.screenshot({ path: path.join(output, `${story.id}.png`), fullPage: true });
}
if (process.env.ORGCHART_STORYBOOK_URL) {
await sb.goto(`${process.env.ORGCHART_STORYBOOK_URL}/iframe.html?id=orgchart-content--basic-organization&viewMode=story`);
await sb.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.hm-orgchart-organization'));
check(await sb.locator('.node').count() === 3, 'development Storybook on reserved separate port executes real Wrapper');
}
check(errors.length === 0, 'browser console/runtime errors are zero across consumer, Vanilla and Storybook', errors);
} catch (error) {
results.push({ name: 'verification completion', status: 'FAIL', detail: error.stack });
process.exitCode = 1;
} finally {
await writeFile(path.join(output, 'report.json'), JSON.stringify({ browser: await browser.version(), results, errors, metrics, snapshots }, null, 2));
await browser.close();
for (const { server } of [consumer, vanilla, storybook]) await new Promise(resolve => server.close(resolve));
console.log(JSON.stringify({ pass: results.filter(result => result.status === 'PASS').length, fail: results.filter(result => result.status === 'FAIL').length, errors, report: path.join(output, 'report.json') }));
}