Files

569 lines
53 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
import http from 'node:http';
import path from 'node:path';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
// Verification tooling is supplied by the environment, never shipped in Core.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p1');
await mkdir(output, { recursive: true });
const fixture = JSON.parse(await readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
await loadPlaywright();
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf', '.woff2': 'font/woff2', '.png': 'image/png', '.svg': 'image/svg+xml' };
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
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 results = [], errors = [], metrics = [], snapshots = [];
const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
const clone = data => structuredClone(data);
const dataWith = (data, version) => { const next = clone(data); next.metadata.dataVersion = version; return next; };
function captureErrors(page, label) {
page.on('pageerror', error => errors.push({ label, type: 'pageerror', message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ label, type: 'console', message: message.text() }); });
}
async function ready(page, transport, version) {
await page.waitForFunction(({ transport, version }) => {
const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React;
const state = transport === 'vanilla' ? api?.chart?.getState() : api?.events?.at(-1);
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return state?.status === 'ready' && state.dataVersion === version && shadow?.querySelector('.hm-orgchart-composition .node');
}, { transport, version }, { timeout: 20000 });
await page.evaluate(async () => {
await document.fonts.ready;
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
await Promise.all([...shadow.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(image => image.complete ? Promise.resolve() : new Promise(resolve => {
image.addEventListener('load', resolve, { once: true }); image.addEventListener('error', resolve, { once: true });
})));
});
}
async function setData(page, transport, data) {
await page.evaluate(async ({ transport, data }) => {
const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React;
await api.setData(data);
}, { transport, data });
await ready(page, transport, data.metadata.dataVersion);
}
/** Transport-neutral snapshot: no generated IDs, stylesheet hashes or host URLs. */
async function snapshot(page) {
return page.evaluate(() => {
const host = document.querySelector('.hm-orgchart-host');
const shadow = host.shadowRoot;
const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
const employees = scope => [...scope.querySelectorAll('.hm-orgchart-employee')].map(card => ({
id: card.dataset.employeeId, kind: card.dataset.cardKind, status: card.dataset.workStatus,
name: text(card.querySelector('.hm-orgchart-person-name')), position: text(card.querySelector('.hm-orgchart-person-role')),
jobTitle: text(card.querySelector('.hm-orgchart-job-title')),
affiliation: card.querySelector('.hm-orgchart-affiliation-mark')?.dataset.affiliationId ?? null,
}));
return {
heading: text(shadow.querySelector('.hm-orgchart-heading')),
title: text(shadow.querySelector('.hm-orgchart-title')),
hub: text(shadow.querySelector('.hm-orgchart-hub')),
nodes: [...shadow.querySelectorAll('.node')].map(node => {
const hierarchy = node.closest('.hierarchy');
const parent = hierarchy?.parentElement?.closest('.hierarchy')?.querySelector(':scope > .node');
return { id: node.dataset.organizationId, type: node.dataset.organizationType, depth: Number(node.dataset.depth), parentId: parent?.dataset.organizationId ?? null,
name: text(node.querySelector('.hm-orgchart-organization-name')), employees: employees(node) };
}),
topLeaders: employees(shadow.querySelector('.hm-orgchart-top-leaders') ?? document.createElement('div')),
components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component),
positions: shadow.querySelector('.hm-orgchart-position-summary') ? {
total: text(shadow.querySelector('.hm-orgchart-position-summary .hm-orgchart-raw-total')),
policy: [...shadow.querySelectorAll('.hm-orgchart-position-summary .hm-orgchart-policy')].map(text),
headers: [...shadow.querySelectorAll('.hm-orgchart-position-table thead th')].map(text),
rows: [...shadow.querySelectorAll('.hm-orgchart-position-table tbody tr')].map(row => ({ id: row.dataset.organizationId, name: text(row.querySelector('th')), cells: [...row.querySelectorAll('td')].map(text) })),
totals: [...shadow.querySelectorAll('.hm-orgchart-position-table tfoot td')].map(text),
statuses: text(shadow.querySelector('.hm-orgchart-status-totals')),
} : null,
affiliations: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item')].map(item => ({
id: item.dataset.affiliationId, count: text(item.querySelector('dd')),
label: item.querySelector('.hm-orgchart-affiliation-mark')?.getAttribute('aria-label'),
logo: item.querySelector('.hm-orgchart-affiliation-mark img')?.getAttribute('src') ?? null,
})),
affiliationTotal: text(shadow.querySelector('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total')),
statuses: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(group => ({ status: group.dataset.workStatus,
heading: text(group.querySelector('h3')), employees: employees(group), empty: text(group.querySelector('.hm-orgchart-content-empty')) })),
};
});
}
function expectedCounts(data) {
const employees = [...new Map(data.employees.map(employee => [employee.id, employee])).values()];
const organizations = new Map(data.organizations.map(org => [org.id, org]));
const positions = new Map(), affiliations = new Map(data.affiliations.map(item => [item.id, 0]));
const direct = new Map(data.organizations.map(org => [org.id, 0]));
const inclusive = new Map(direct), matrix = new Map(data.organizations.map(org => [org.id, new Map()]));
const statuses = { active: 0, dispatched: 0, onLeave: 0 };
for (const employee of employees) {
positions.set(employee.position, (positions.get(employee.position) ?? 0) + 1);
affiliations.set(employee.affiliationId, (affiliations.get(employee.affiliationId) ?? 0) + 1);
direct.set(employee.organizationId, direct.get(employee.organizationId) + 1);
const row = matrix.get(employee.organizationId); row.set(employee.position, (row.get(employee.position) ?? 0) + 1);
statuses[employee.workStatus]++;
let org = organizations.get(employee.organizationId);
while (org) { inclusive.set(org.id, inclusive.get(org.id) + 1); org = organizations.get(org.parentId); }
}
return { total: employees.length, employees, positions, affiliations, direct, inclusive, matrix, statuses };
}
function checkCounts(current, data, label) {
const expected = expectedCounts(data);
const positionNames = current.positions.headers.slice(1, -2);
check(current.positions.rows.length === data.organizations.length, `${label}: every organization has a cross-table row`);
check(current.positions.rows.every(row => {
const cells = row.cells.map(Number);
return positionNames.every((position, index) => cells[index] === (expected.matrix.get(row.id)?.get(position) ?? 0))
&& cells.at(-2) === expected.direct.get(row.id) && cells.at(-1) === expected.inclusive.get(row.id);
}), `${label}: direct organization×position cells and inclusive counts match unique primary membership`, current.positions.rows);
check(positionNames.every((position, index) => Number(current.positions.totals[index]) === expected.positions.get(position))
&& Number(current.positions.totals.at(-2)) === expected.total && current.positions.totals.at(-1) === '—', `${label}: direct totals are disjoint; inclusive counts are not added`, current.positions.totals);
check(current.positions.total.includes(`${expected.total}명`) && current.affiliationTotal.includes(`${expected.total}명`), `${label}: Position and Affiliation totals reuse the same Raw Count`);
check(current.affiliations.length === data.affiliations.length && current.affiliations.every(item => parseInt(item.count) === expected.affiliations.get(item.id)), `${label}: every declared affiliation count is correct`, current.affiliations.map(({ id, count }) => ({ id, count })));
check(current.statuses.every(group => {
const matching = expected.employees.filter(employee => employee.workStatus === group.status);
return parseInt(group.heading.split('·').at(-1)) === matching.length && same(group.employees.map(employee => employee.id).sort(), matching.map(employee => employee.id).sort());
}), `${label}: status groups use original employee IDs without inflating total`, current.statuses.map(group => ({ status: group.status, ids: group.employees.map(employee => employee.id) })));
check(current.positions.policy.some(text => text.includes('총원 미확정')) && current.positions.policy.some(text => text.includes('조직장') && text.includes('파견') && text.includes('휴직')), `${label}: Raw inclusion policy is visible; business total remains unapproved`);
}
async function measure(page) {
return page.evaluate(() => {
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host');
const shadow = host.shadowRoot;
const viewport = shadow.querySelector('.hm-orgchart-viewport');
const summary = shadow.querySelector('.hm-orgchart-summary-scroll');
const dimensions = element => element && ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX });
const textElements = [...shadow.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-organization-name')].filter(element => element.getBoundingClientRect().height > 0);
const cards = [...shadow.querySelectorAll('.node')].map(element => element.getBoundingClientRect()).filter(rect => rect.height > 0);
const sample = shadow.querySelector('.hm-orgchart-employee, .hm-orgchart-position-summary');
return {
document: dimensions(document.documentElement), host: dimensions(host), viewport: dimensions(viewport), summary: dimensions(summary),
ids: [...shadow.querySelectorAll('.hm-orgchart-domain-id')].map(element => ({ text: element.textContent, title: element.title, whiteSpace: getComputedStyle(element).whiteSpace, height: element.clientHeight, scrollHeight: element.scrollHeight })),
textFits: textElements.every(element => element.scrollWidth <= element.clientWidth + 1 && element.scrollHeight <= element.clientHeight + 1),
textIssues: textElements.filter(element => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1).map(element => ({ className: element.className, text: element.textContent, ...dimensions(element) })),
nodeOverlap: cards.some((a, index) => cards.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)),
font: sample ? getComputedStyle(sample).fontFamily : null, foreground: sample ? getComputedStyle(sample).color : null,
background: sample ? getComputedStyle(sample).backgroundColor : null, radius: sample ? getComputedStyle(sample).borderRadius : null,
tokens: [...getComputedStyle(shadow.querySelector('.hm-orgchart-shell'))].filter(key => key.startsWith('--hm-orgchart-')).map(key => ({ key, value: getComputedStyle(shadow.querySelector('.hm-orgchart-shell')).getPropertyValue(key).trim() })),
cssLoaded: [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet),
portraitsFallback: [...shadow.querySelectorAll('.hm-orgchart-avatar')].every(avatar => avatar.textContent.trim() && !avatar.querySelector('img')),
logos: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item .hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, available: mark.dataset.logoAvailable, src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })),
navigationCount: shadow.querySelectorAll('nav, .hm-dashboard-header, [data-slot=sidebar], .hm-gnb, .hm-lnb').length,
};
});
}
async function styleIsolation(page, transport) {
const evidence = await page.evaluate(async () => {
const host = document.querySelector('.hm-orgchart-host');
const shadow = host.shadowRoot;
const sample = shadow.querySelector('.hm-orgchart-employee[data-card-kind=employee]');
const signature = () => {
const style = getComputedStyle(sample);
return { foreground: style.color, background: style.backgroundColor, font: style.fontFamily, radius: style.borderRadius, border: style.borderTopColor };
};
const initial = signature();
// Document CSS must be optional; private Shadow stylesheet links stay mounted.
const documentLinks = [...document.querySelectorAll('link[rel=stylesheet]')];
documentLinks.forEach(link => link.remove());
await new Promise(resolve => requestAnimationFrame(resolve));
const withoutTheme = signature();
for (const [property, value] of [['--hm-sem-bg-surface', '#ff0000'], ['--hm-sem-fg-primary', '#ff0000'], ['--hm-radius-container', '99px'], ['--hm-font-family-base', 'monospace']]) host.style.setProperty(property, value);
const alteredHM = signature();
const sentinel = document.createElement('span');
sentinel.textContent = 'Host style isolation sentinel';
sentinel.style.color = 'rgb(55, 66, 77)'; sentinel.style.backgroundColor = 'rgb(88, 99, 110)';
document.body.append(sentinel);
const sentinelBefore = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor };
// Public inputs intentionally remain undeclared; defaults resolve on the
// shadow shell so a host input can inherit without being masked by :host.
const token = '--hm-orgchart-bg-surface';
host.style.setProperty(token, 'rgb(7, 23, 41)');
const scopedOverride = signature();
const sentinelAfter = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor };
if (token) host.style.removeProperty(token);
for (const property of ['--hm-sem-bg-surface', '--hm-sem-fg-primary', '--hm-radius-container', '--hm-font-family-base']) host.style.removeProperty(property);
sentinel.remove();
await Promise.all(documentLinks.map(link => new Promise(resolve => {
link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', resolve, { once: true }); document.head.append(link);
})));
return { initial, withoutTheme, alteredHM, scopedOverride, token, sentinelBefore, sentinelAfter, restored: signature(), removedDocumentStyles: documentLinks.length };
});
metrics.push({ transport, stage: 'private-style-isolation', ...evidence });
check(same(evidence.initial, evidence.withoutTheme), `${transport}: Core style is unchanged without host HM Theme CSS`, evidence);
check(same(evidence.withoutTheme, evidence.alteredHM), `${transport}: unrelated host HM color/radius/font variables cannot change private OrgChart style`, evidence);
check(evidence.token && evidence.scopedOverride.background === 'rgb(7, 23, 41)' && same(evidence.sentinelBefore, evidence.sentinelAfter), `${transport}: scoped OrgChart surface override affects its instance only`, evidence);
check(same(evidence.initial, evidence.restored), `${transport}: removing the scoped override restores standalone defaults`, evidence);
}
async function standaloneFactories(page) {
const evidence = await page.evaluate(async data => {
const api = window.OrgChartP1.api;
const stylesheetUrls = window.OrgChartP1.stylesheetUrls;
const existing = document.querySelector('.hm-orgchart-host');
const originalBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor;
const results = [];
for (const [factory, selector] of [['createPositionSummary', '.hm-orgchart-position-summary'], ['createAffiliationSummary', '.hm-orgchart-affiliation-summary'], ['createStatusSummary', '.hm-orgchart-status-summary']]) {
const container = document.createElement('div'); container.style.maxWidth = '760px'; document.body.append(container);
const events = [];
const handle = api[factory]({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrl: stylesheetUrls.core, onStateChange: state => events.push(state) });
await handle.mount();
const shadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
const mounted = { state: handle.getState().status, total: handle.getState().summary?.raw.total,
modules: shadow.querySelectorAll(selector).length, nodes: shadow.querySelectorAll('.node').length,
foreign: shadow.querySelectorAll('.hm-orgchart-composition, .hm-orgchart-heading, .hm-orgchart-hub, .hm-orgchart-viewport').length,
links: [...shadow.querySelectorAll('link')].map(link => link.getAttribute('href')),
headings: shadow.textContent,
};
const zero = structuredClone(data); zero.metadata.dataVersion = `${factory}-zero`;
zero.employees = []; for (const org of zero.organizations) org.leaderId = null;
await handle.update(zero);
const currentShadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
const updated = { state: handle.getState().status, version: handle.getState().dataVersion, total: handle.getState().summary?.raw.total,
employees: currentShadow.querySelectorAll('[data-employee-id]').length,
affiliations: [...currentShadow.querySelectorAll('.hm-orgchart-affiliation-count')].map(element => element.textContent),
emptyGroups: [...currentShadow.querySelectorAll('.hm-orgchart-content-empty')].map(element => element.textContent),
};
const sibling = document.createElement('p'); sibling.textContent = 'Host-owned sibling'; container.append(sibling);
handle.destroy(); handle.destroy();
results.push({ factory, mounted, updated, eventStatuses: events.map(state => state.status),
destroyed: handle.getState().status, remaining: container.children.length, siblingPreserved: sibling.isConnected });
container.remove();
}
const container = document.createElement('div'); document.body.append(container);
const tree = api.createOrgChartTree({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrls });
await tree.mount();
const treeShadow = container.querySelector('.hm-orgchart-host').shadowRoot;
const onlyTree = { ready: tree.getState().status, nodes: treeShadow.querySelectorAll('.node').length,
employeeIDs: [...new Set([...treeShadow.querySelectorAll('[data-employee-id]')].map(element => element.dataset.employeeId))].sort(),
forbidden: treeShadow.querySelectorAll('.hm-orgchart-heading,.hm-orgchart-hub,.hm-orgchart-composition,.hm-orgchart-position-summary,.hm-orgchart-affiliation-summary,.hm-orgchart-status-summary').length,
viewport: treeShadow.querySelectorAll('.hm-orgchart-viewport').length,
};
const moved = structuredClone(data); moved.metadata.dataVersion = 'standalone-tree-updated';
moved.employees.find(employee => employee.id === 'mock-employee-01').organizationId = 'solution-sales-1';
await tree.update(moved);
const nextShadow = container.querySelector('.hm-orgchart-host').shadowRoot;
onlyTree.updatedVersion = tree.getState().dataVersion;
onlyTree.employeeMoved = !![...nextShadow.querySelectorAll('.node')].find(node => node.dataset.organizationId === 'solution-sales-1')?.querySelector('[data-employee-id="mock-employee-01"]');
tree.destroy(); onlyTree.destroyed = container.children.length === 0; container.remove();
const existingBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor;
return { summaries: results, tree: onlyTree, originalBackground, existingBackground };
}, fixture);
metrics.push({ stage: 'standalone-public-factories', ...evidence });
for (const item of evidence.summaries) {
check(item.mounted.state === 'ready' && item.mounted.total === 41 && item.mounted.modules === 1 && item.mounted.nodes === 0 && item.mounted.foreign === 0 && item.mounted.links.length === 1, `${item.factory}: public factory mounts only its own summary without engine, heading or composition`, item.mounted);
check(item.updated.state === 'ready' && item.updated.version === `${item.factory}-zero` && item.updated.total === 0 && item.updated.employees === 0
&& (item.factory !== 'createAffiliationSummary' || item.updated.affiliations.length === 7 && item.updated.affiliations.every(text => text === '0명'))
&& (item.factory !== 'createStatusSummary' || item.updated.emptyGroups.length === 2), `${item.factory}: update refreshes shared Raw Count and removes stale cards`, item.updated);
check(item.destroyed === 'destroyed' && item.remaining === 1 && item.siblingPreserved, `${item.factory}: destroy releases only owned auxiliary DOM and preserves host siblings`, item);
}
check(evidence.tree.ready === 'ready' && evidence.tree.nodes === 18 && evidence.tree.viewport === 1 && evidence.tree.forbidden === 0
&& same(evidence.tree.employeeIDs, fixture.employees.map(employee => employee.id).sort()), 'createOrgChartTree: public standalone tree preserves all source employees with no heading/hub/summary composition', evidence.tree);
check(evidence.tree.updatedVersion === 'standalone-tree-updated' && evidence.tree.employeeMoved && evidence.tree.destroyed, 'createOrgChartTree: standalone update and destroy retain engine lifecycle', evidence.tree);
check(evidence.originalBackground === evidence.existingBackground, 'standalone factories do not alter the existing full-composition instance');
}
const expectedOldIds = ['basic-organization', 'organization-hierarchy', 'employee-cards', 'organization-summary', 'json-data-update', 'loading-empty-error', 'responsive-container', 'multiple-instances'].map(id => `orgchart-content--${id}`);
const canonicalStoryIds = ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'].map(id => `orgchart--${id}`);
const internalStoryIds = [
...['business-management', 'business-planning', 'talent-growth', 'integrated-system', 'design-planning', 'solution-sales', 'top-leader', 'organization-leader', 'employee'].map(id => `orgchart-components-cards--${id}`),
...['position-summary', 'affiliation-summary', 'status-summary'].map(id => `orgchart-components-summaries--${id}`),
];
// Canonical entries cover every component once; only the three distinct employee
// kinds additionally need their internal preview. Registry preservation is static.
const renderedStoryIds = [...canonicalStoryIds, ...internalStoryIds.slice(6, 9)];
const measuredGradients = [
['#194d82', '#1d395c', 'rgb(25, 77, 130)', 'rgb(29, 57, 92)'],
['#2164a8', '#254975', 'rgb(33, 100, 168)', 'rgb(37, 73, 117)'],
['#0f6ace', '#044a94', 'rgb(15, 106, 206)', 'rgb(4, 74, 148)'],
['#8c67b6', '#5d3494', 'rgb(140, 103, 182)', 'rgb(93, 52, 148)'],
['#09a7a2', '#00847f', 'rgb(9, 167, 162)', 'rgb(0, 132, 127)'],
['#5a963c', '#3f7629', 'rgb(90, 150, 60)', 'rgb(63, 118, 41)'],
];
/** A Story metadata fix only invalidates its console checks. Preserve the full
* browser.json run and recheck those five iframes plus the newly requested
* actual manager sidebar, without repeating Core/React/summary/width checks. */
async function recheckStorybookConsole() {
const retryResults = [], retryErrors = [], rendered = [];
const checkRetry = (condition, name, detail) => retryResults.push({ name, status: condition ? 'PASS' : 'FAIL', detail });
const server = await serve(path.join(root, 'storybook-static'));
let retryBrowser;
let sidebar;
try {
retryBrowser = await launchChromium({ headless: true });
const context = await retryBrowser.newContext({ viewport: { width: 1440, height: 1080 } });
const iframe = await context.newPage();
iframe.on('pageerror', error => retryErrors.push({ surface: 'iframe', type: 'pageerror', message: error.message }));
iframe.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'iframe', type: 'console', message: message.text() }); });
for (const id of canonicalStoryIds) {
await iframe.goto(`${server.origin}/iframe.html?id=${id}&viewMode=story`);
await iframe.waitForFunction(() => {
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet);
}, undefined, { timeout: 20000 });
rendered.push(id);
}
checkRetry(retryErrors.filter(error => error.surface === 'iframe').length === 0 && rendered.length === 5, 'five corrected canonical Story iframes emit no console/runtime errors', { rendered, errors: retryErrors.filter(error => error.surface === 'iframe') });
const manager = await context.newPage();
manager.on('pageerror', error => retryErrors.push({ surface: 'manager', type: 'pageerror', message: error.message }));
manager.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'manager', type: 'console', message: message.text() }); });
await manager.goto(`${server.origin}/?path=/story/orgchart--full-composition`);
await manager.waitForSelector('#storybook-explorer-tree [data-item-id="orgchart--full-composition"]', { timeout: 20000 });
sidebar = await manager.evaluate(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].filter(row => row.dataset.itemId.startsWith('orgchart') && row.getBoundingClientRect().height > 0).map(row => ({ id: row.dataset.itemId, label: row.textContent.trim() })));
const visibleStories = sidebar.filter(row => row.id.startsWith('orgchart--')).map(row => row.id).sort();
checkRetry(same(visibleStories, [...canonicalStoryIds].sort()), 'actual Storybook sidebar exposes exactly the approved five OrgChart Story entries', sidebar);
checkRetry(sidebar.every(row => !row.id.startsWith('orgchart-content') && !row.id.startsWith('orgchart-components')),
'actual default sidebar hides the original/internal reference groups while preserving their direct routes', sidebar);
await manager.screenshot({ path: path.join(output, 'storybook-sidebar.png'), fullPage: true });
checkRetry(retryErrors.filter(error => error.surface === 'manager').length === 0, 'actual Storybook manager and its preview emit no console/runtime errors', retryErrors.filter(error => error.surface === 'manager'));
} catch (error) {
retryResults.push({ name: 'selected Storybook console/sidebar recheck completion', status: 'FAIL', detail: error.stack });
} finally {
const report = { browser: retryBrowser?.version() ?? null, mode: 'storybook-console-only', preservedInitialReport: path.join(output, 'browser.json'), results: retryResults, errors: retryErrors, rendered, sidebar,
limits: ['196 previously successful Core/React/component checks were not repeated', 'This run only rechecks corrected Story metadata and newly verifies the default manager sidebar'] };
const reportPath = path.join(output, 'storybook-console-recheck.json');
await writeFile(reportPath, JSON.stringify(report, null, 2));
if (retryBrowser) await retryBrowser.close();
await new Promise(resolve => server.server.close(resolve));
const failed = retryResults.filter(result => result.status === 'FAIL');
if (failed.length) process.exitCode = 1;
console.log(JSON.stringify({ passed: retryResults.filter(result => result.status === 'PASS').length, failed, report: reportPath, sidebarScreenshot: path.join(output, 'storybook-sidebar.png') }, null, 2));
}
}
if (process.argv.includes('--storybook-console-only')) {
await recheckStorybookConsole();
process.exit(process.exitCode ?? 0);
}
let browser;
const servers = [];
try {
const productionStyles = await readFile(path.join(root, 'packages/org-chart/dist/style.css'), 'utf8');
const foreignHMVariables = [...productionStyles.matchAll(/var\(\s*(--hm-(?!orgchart-)[a-z0-9-]+)/g)].map(match => match[1]);
check(foreignHMVariables.length === 0, 'shipped Core stylesheet references only the private OrgChart namespace rather than host HM Theme variables', [...new Set(foreignHMVariables)]);
const vanilla = await serve(path.join(root, 'examples/org-chart/dist')); servers.push(vanilla);
const react = await serve(path.join(root, 'examples/org-chart-react/dist')); servers.push(react);
const storybook = await serve(path.join(root, 'storybook-static')); servers.push(storybook);
browser = await launchChromium({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1080 } });
const pages = new Map();
for (const [transport, origin] of [['vanilla', vanilla.origin], ['react', react.origin]]) {
const page = await context.newPage(); pages.set(transport, page); captureErrors(page, transport);
await page.goto(`${origin}/p1.html`);
await ready(page, transport, fixture.metadata.dataVersion);
const initial = await snapshot(page); snapshots.push({ transport, stage: 'initial', value: initial });
check(initial.nodes.length === 18 && expectedCounts(fixture).total === 41, `${transport}: P1 fixture contains 18 real-name organizations and 41 virtual employee IDs`);
check(initial.title === fixture.presentation.title && initial.hub === fixture.presentation.hubText, `${transport}: heading and independent hub come from JSON`);
check(initial.nodes.every(node => fixture.organizations.find(org => org.id === node.id)?.parentId === node.parentId), `${transport}: rendered hierarchy retains every original parentId`, initial.nodes.map(({ id, parentId }) => ({ id, parentId })));
const topEmployee = fixture.organizations.find(org => org.parentId === null)?.leaderId;
check(initial.topLeaders.length === 1 && initial.topLeaders[0].id === topEmployee && initial.topLeaders[0].kind === 'topLeader'
&& initial.nodes.every(node => node.employees.every(employee => employee.id !== topEmployee)), `${transport}: top leader is independent and is not duplicated in the tree`);
check(initial.nodes.filter(node => node.depth === 1).length === 6 && initial.components.includes('OrgChartTree'), `${transport}: six primary organization groups preserve the engine tree boundary`);
check(['PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => initial.components.filter(value => value === name).length === 1), `${transport}: three independent summary modules are composed once`);
checkCounts(initial, fixture, `${transport}: initial`);
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1080 });
const measurement = await measure(page); metrics.push({ transport, stage: 'initial', width, ...measurement });
check(measurement.document.scrollWidth <= measurement.document.clientWidth + 1, `${transport}: ${width}px document has no horizontal overflow`, measurement.document);
check(measurement.viewport.scrollWidth > measurement.viewport.clientWidth && measurement.viewport.overflowX === 'auto', `${transport}: ${width}px wide tree uses internal scrolling`, measurement.viewport);
check(measurement.textFits && !measurement.nodeOverlap && measurement.ids.every(item => item.whiteSpace === 'nowrap' && item.title === item.text && item.scrollHeight <= item.height + 1), `${transport}: ${width}px text bounds, node separation and one-line IDs`, { textIssues: measurement.textIssues, overlap: measurement.nodeOverlap, ids: measurement.ids });
check(measurement.cssLoaded && measurement.tokens.length >= 10 && measurement.tokens.every(item => item.value) && measurement.font && measurement.background !== 'rgba(0, 0, 0, 0)' && measurement.radius !== '0px', `${transport}: ${width}px independent OrgChart defaults and loaded Shadow styles`, { tokens: measurement.tokens, font: measurement.font, background: measurement.background, radius: measurement.radius });
check(measurement.logos.length === 7 && measurement.logos.every(item => item.available === 'true' && item.loaded && item.src.startsWith('data:image/')), `${transport}: ${width}px all seven acquired affiliation logos load`, measurement.logos.map(({ id, available, loaded }) => ({ id, available, loaded })));
check(measurement.portraitsFallback && measurement.navigationCount === 0, `${transport}: ${width}px virtual portrait fallback and content-only UI`);
await page.screenshot({ path: path.join(output, `${transport}-${width}.png`), fullPage: true });
}
await page.setViewportSize({ width: 1440, height: 1080 });
await styleIsolation(page, transport);
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' && await toggle.evaluate(element => element.getRootNode().activeElement === element), `${transport}: keyboard Enter collapses hierarchy and keeps focus`);
await toggle.press('Space');
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
check(await toggle.getAttribute('aria-expanded') === 'true', `${transport}: keyboard Space expands hierarchy`);
}
check(same(snapshots.find(item => item.transport === 'vanilla').value, snapshots.find(item => item.transport === 'react').value), 'HTML and React initial Core DOM and automatic summary snapshots are identical');
const changed = dataWith(fixture, 'p1-browser-json-v2');
const moved = changed.employees.find(employee => employee.id === 'mock-employee-01');
moved.organizationId = 'solution-sales-1'; moved.affiliationId = 'halla'; moved.workStatus = 'onLeave';
moved.name = '가상직원 긴 이름과 콘텐츠 줄바꿈 검증 01'; moved.position = '가상 장기 프로젝트 연구 및 운영 담당 수석';
moved.jobTitle = '가상 프로젝트 담당';
changed.presentation.title = 'JSON 갱신 · 가상 직원 이동 및 상태 변경';
for (const [transport, page] of pages) {
await page.locator('.hm-orgchart-summary-scroll').focus();
await setData(page, transport, changed);
check(await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.classList.contains('hm-orgchart-viewport')), `${transport}: summary keyboard focus returns to stable tree after composition regeneration`);
const current = await snapshot(page); snapshots.push({ transport, stage: 'changed', value: current });
check(current.title === changed.presentation.title && current.nodes.find(node => node.id === moved.organizationId)?.employees.some(employee => employee.id === moved.id && employee.name === moved.name && employee.position === moved.position), `${transport}: changed JSON moves and updates the same employee card`);
checkCounts(current, changed, `${transport}: changed JSON`);
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1080 });
const measurement = await measure(page); metrics.push({ transport, stage: 'long-text', width, ...measurement });
check(measurement.textFits && measurement.document.scrollWidth <= width + 1, `${transport}: ${width}px long employee name, position and job title fit with contained table scrolling`, { textIssues: measurement.textIssues, document: measurement.document, summary: measurement.summary });
}
check(current.statuses.find(group => group.status === 'onLeave').employees.some(employee => employee.id === moved.id) && !current.statuses.find(group => group.status === 'dispatched').employees.some(employee => employee.id === moved.id), `${transport}: state groups update without stale employee cards`);
// Flags select independent components rather than alter the employee source or counts.
const cases = [
['hide-position', { showSummary: false }, { position: false, affiliations: true, dispatched: true, onLeave: true }],
['hide-affiliation', { showAffiliations: false }, { position: true, affiliations: false, dispatched: true, onLeave: true }],
['hide-dispatched', { showDispatched: false }, { position: true, affiliations: true, dispatched: false, onLeave: true }],
['hide-onleave', { showOnLeave: false }, { position: true, affiliations: true, dispatched: true, onLeave: false }],
['hide-both-status', { showDispatched: false, showOnLeave: false }, { position: true, affiliations: true, dispatched: false, onLeave: false }],
];
for (const [name, flags, expected] of cases) {
const data = dataWith(changed, `${transport}-${name}`); Object.assign(data.presentation.groups, flags);
await setData(page, transport, data);
const current = await snapshot(page);
check(!!current.positions === expected.position && !!current.affiliations.length === expected.affiliations
&& current.statuses.some(group => group.status === 'dispatched') === expected.dispatched
&& current.statuses.some(group => group.status === 'onLeave') === expected.onLeave
&& current.components.includes('StatusSummary') === (expected.dispatched || expected.onLeave), `${transport}: ${name} changes only its independent component`, current.components);
check(!current.positions || Number(current.positions.totals.at(-2)) === 41, `${transport}: ${name} cannot alter Raw Count`);
}
const zeroAffiliation = dataWith(changed, `${transport}-zero-affiliation`);
for (const employee of zeroAffiliation.employees) if (employee.affiliationId === 'baron') employee.affiliationId = 'hanmac';
await setData(page, transport, zeroAffiliation);
check((await snapshot(page)).affiliations.find(item => item.id === 'baron')?.count === '0명', `${transport}: zero-count declared affiliations stay visible`);
const zeroStatus = dataWith(changed, `${transport}-zero-status`);
for (const employee of zeroStatus.employees) employee.workStatus = 'active';
await setData(page, transport, zeroStatus);
check((await snapshot(page)).statuses.every(group => group.employees.length === 0 && group.empty && group.heading.includes('0명')), `${transport}: enabled empty status groups have explicit empty content`);
// A successful HTTP response containing non-image bytes triggers img.error,
// avoiding expected network-console failures while testing the actual fallback.
await page.route('**/p1-invalid-portrait.png', route => route.fulfill({ status: 200, contentType: 'image/png', body: 'not a png' }));
const brokenImage = dataWith(changed, `${transport}-image-fallback`);
brokenImage.employees.find(employee => employee.id === moved.id).profileImage = './p1-invalid-portrait.png';
await setData(page, transport, brokenImage);
const brokenCards = page.locator(`.hm-orgchart-employee[data-employee-id="${moved.id}"]`);
for (let index = 0; index < await brokenCards.count(); index++) await brokenCards.nth(index).scrollIntoViewIfNeeded();
await page.waitForFunction(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => !card.querySelector('.hm-orgchart-avatar img')), moved.id);
check(await page.evaluate(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => card.querySelector('.hm-orgchart-avatar').textContent.trim()), moved.id), `${transport}: invalid profile images safely return to initials`);
await page.unroute('**/p1-invalid-portrait.png');
await setData(page, transport, dataWith(changed, 'p1-browser-final-changed'));
snapshots.push({ transport, stage: 'restored-changed', value: await snapshot(page) });
check(transport !== 'react' || await page.evaluate(() => window.OrgChartP1React.errors.length === 0), `${transport}: auxiliary regeneration has no lifecycle error`);
}
check(same(...snapshots.filter(item => item.stage === 'restored-changed').map(item => item.value)), 'HTML and React Core snapshots remain identical after JSON and independent visibility updates');
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
const old = Object.values(index.entries).filter(entry => entry.title === 'OrgChart/Content' && entry.type === 'story');
check(old.length === 8 && same(old.map(entry => entry.id).sort(), [...expectedOldIds].sort()) && old.every(entry => entry.tags.includes('internal')), 'all original eight Storybook entries remain registered as internal without re-running their baseline suite', old.map(entry => entry.id));
check(internalStoryIds.every(id => index.entries[id]?.type === 'story' && index.entries[id].tags.includes('internal')), 'twelve isolated card/summary Stories are preserved as internal references', internalStoryIds);
const visible = Object.values(index.entries).filter(entry => entry.title === 'OrgChart' && entry.type === 'story' && !entry.tags.includes('internal')).map(entry => entry.id).sort();
check(same(visible, [...canonicalStoryIds].sort()), 'canonical OrgChart offers exactly the five approved user-visible entries', visible);
const sb = await context.newPage(); captureErrors(sb, 'storybook');
for (const id of renderedStoryIds) {
if (!index.entries[id]) continue;
await sb.goto(`${storybook.origin}/iframe.html?id=${id}&viewMode=story`);
await sb.waitForFunction(() => {
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host');
const shadow = host?.shadowRoot;
return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet);
}, undefined, { timeout: 20000 });
await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-affiliation-mark img')].every(image => image.complete));
const component = await sb.evaluate(() => {
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'), shadow = host.shadowRoot;
const organizationName = shadow.querySelector('.hm-orgchart-organization-name');
const node = shadow.querySelector('.node');
return { components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component), nodeCount: shadow.querySelectorAll('.node').length,
employees: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, kind: card.dataset.cardKind })),
gradients: organizationName ? { background: getComputedStyle(organizationName).backgroundImage, variable: getComputedStyle(node).getPropertyValue('--hm-orgchart-group-background').trim() } : null,
body: document.body.textContent, title: organizationName?.textContent,
hasPosition: !!shadow.querySelector('.hm-orgchart-position-summary'), hasAffiliation: !!shadow.querySelector('.hm-orgchart-affiliation-summary'), hasStatus: !!shadow.querySelector('.hm-orgchart-status-summary'),
linksLoaded: [...shadow.querySelectorAll('link')].every(link => link.sheet),
};
});
snapshots.push({ transport: 'storybook', stage: id, value: component });
check(component.linksLoaded, `${id}: real Core preview and Shadow CSS load`);
if (id === 'orgchart--position-summary' || id === 'orgchart--affiliation-summary') {
const kind = id.endsWith('position-summary') ? 'PositionSummary' : id.endsWith('affiliation-summary') ? 'AffiliationSummary' : 'StatusSummary';
check(component.nodeCount === 0 && same(component.components, [kind])
&& component.hasPosition === (kind === 'PositionSummary') && component.hasAffiliation === (kind === 'AffiliationSummary') && component.hasStatus === (kind === 'StatusSummary'), `${id}: standalone summary has exactly its Core module and no engine tree`, component.components);
} else if (id.endsWith('full-composition')) {
check(component.nodeCount === 18 && ['OrgChartTree', 'PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => component.components.includes(name)), `${id}: composition contains shared tree plus all independent summaries`);
for (const width of [390, 768, 1280, 1440]) {
await sb.setViewportSize({ width, height: 1080 });
const measurement = await measure(sb); metrics.push({ transport: 'storybook', stage: id, width, ...measurement });
check(measurement.document.scrollWidth <= width + 1 && measurement.textFits && measurement.navigationCount === 0, `${id}: ${width}px content composition has no document overflow or clipped text`, { document: measurement.document, textIssues: measurement.textIssues });
await sb.screenshot({ path: path.join(output, `${id}-${width}.png`), fullPage: true });
}
await sb.setViewportSize({ width: 1440, height: 1080 });
await sb.getByRole('button', { name: 'PositionSummary 숨기기', exact: true }).click();
await sb.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-position-summary'));
check(await sb.locator('.hm-orgchart-affiliation-summary').count() === 1 && await sb.locator('.hm-orgchart-status-summary').count() === 1, `${id}: Story button independently hides PositionSummary`);
await sb.getByRole('button', { name: '가상 JSON v2 반영', exact: true }).click();
await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('[data-work-status=onLeave] [data-employee-id]')].some(element => element.dataset.employeeId === 'mock-employee-01'));
check(await sb.locator('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total').textContent() === 'Raw Count · 전체 41명', `${id}: Story JSON update reuses 41 unique virtual employees`);
} else if (id.endsWith('top-leader') || id.endsWith('organization-leader') || id.endsWith('--employee')) {
const kind = id.endsWith('top-leader') ? 'topLeader' : id.endsWith('organization-leader') ? 'leader' : 'employee';
check(component.nodeCount === 0 && component.employees.length === 1 && component.employees[0].kind === kind && same(component.components, ['EmployeeCard']), `${id}: standalone ${kind} uses one actual Core employee card`, component.employees);
} else if (id === 'orgchart--department-colors') {
await sb.waitForFunction(() => {
const values = [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-value')];
return values.length === 30 && values.every(element => element.textContent.trim());
});
const colors = await sb.evaluate(() => [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-preview[data-group-variant]')].map(row => ({
variant: Number(row.dataset.groupVariant),
text: row.textContent,
background: getComputedStyle(row.querySelector('[data-color-variable$="-gradient"]')).backgroundImage,
variable: getComputedStyle(row).getPropertyValue('--hm-orgchart-group-background').trim(),
declarations: [...row.querySelectorAll('[data-color-variable]')].map(element => ({ variable: element.dataset.colorVariable, property: element.dataset.cssProperty, applied: getComputedStyle(element).getPropertyValue(element.dataset.cssProperty), valueText: element.parentElement.querySelector('.hm-orgchart-color-value')?.textContent })),
card: (() => { const card = row.querySelector('.hm-orgchart-employee'); const style = getComputedStyle(card); return { background: style.backgroundColor, color: style.color, border: style.borderTopColor }; })(),
})));
check(colors.length === 6, `${id}: all six department color components are rendered`, colors);
for (const color of colors) {
const [start, end, rgbStart, rgbEnd] = measuredGradients[color.variant];
check(color.background.includes('linear-gradient') && color.background.includes(rgbStart) && color.background.includes(rgbEnd)
&& color.variable === `linear-gradient(180deg, ${start} 0%, ${end} 100%)`
&& color.text.includes(start) && color.text.includes(end) && color.declarations.length === 5
&& color.declarations.every(item => item.variable.startsWith('--hm-orgchart-') && item.valueText?.includes('#')), `${id}: variant ${color.variant} applies exact measured gradient and documents HEX/CSS variables`, color);
check(color.declarations.find(item => item.variable.endsWith('-card-background')).applied === color.card.background
&& color.declarations.find(item => item.variable.endsWith('-card-color')).applied === color.card.color
&& color.declarations.find(item => item.variable.endsWith('-border')).applied === color.card.border, `${id}: variant ${color.variant} legend values match the actual card styles`, color.card);
}
} else if (id === 'orgchart--logo') {
const logos = await sb.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot;
return { marks: [...shadow.querySelectorAll('.hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, label: mark.getAttribute('aria-label'), src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })), text: shadow.textContent,
counts: shadow.querySelectorAll('.hm-orgchart-affiliation-count, .hm-orgchart-raw-total, .hm-orgchart-position-table').length };
});
check(component.nodeCount === 0 && component.components.includes('Logo') && logos.marks.length === 7 && logos.marks.every(mark => mark.loaded && mark.src.startsWith('data:image/') && mark.label)
&& fixture.affiliations.every(affiliation => logos.text.includes(affiliation.name)) && logos.counts === 0, `${id}: standalone Logo uses all seven named assets without statistics`, logos.marks.map(({ id, label, loaded }) => ({ id, label, loaded })));
}
}
await standaloneFactories(pages.get('vanilla'));
await pages.get('vanilla').evaluate(() => window.OrgChartP1.chart.destroy());
check(await pages.get('vanilla').locator('.hm-orgchart-host').count() === 0, 'destroy releases composed tree and all auxiliary summary DOM');
check(await pages.get('react').evaluate(() => window.OrgChartP1React.errors.length === 0), 'React wrapper lifecycle error boundary remains clear');
check(errors.length === 0, 'targeted P1 Vanilla, React and canonical/employee Stories have no console/runtime errors', errors);
} catch (error) {
results.push({ name: 'P1 browser harness completion', status: 'FAIL', detail: error.stack });
} finally {
const report = { browser: browser?.version() ?? null, results, errors, metrics, snapshots,
limits: ['Figma Dev Mode detailed properties NOT VERIFIED', 'Workdesk pixel parity NOT VERIFIED; gradient checks verify the recorded measured header sources only', 'Screen-reader announcements and heap/GC behavior NOT VERIFIED', 'Original 69/79 browser suites deliberately not repeated'] };
await writeFile(path.join(output, 'browser.json'), JSON.stringify(report, null, 2));
if (browser) await browser.close();
for (const { server } of servers) await new Promise(resolve => server.close(resolve));
const failed = results.filter(result => result.status === 'FAIL');
if (failed.length) process.exitCode = 1;
console.log(JSON.stringify({ passed: results.filter(result => result.status === 'PASS').length, failed, report: path.join(output, 'browser.json') }, null, 2));
}