Files

256 lines
26 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 fs from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
// P3.2 change-specific evidence. Existing P3.1 486-check evidence is preserved.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p3.2');
const baselineMode = process.argv.includes('--baseline');
const policiesOnly = process.argv.includes('--policies-only');
const fixtureBytes = await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'));
const fixture = JSON.parse(fixtureBytes);
await loadPlaywright();
const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }];
const storyOrigin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6041';
const results = [], measurements = [], errors = [], servers = [];
const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const near = (a, b, tolerance = .4) => Number.isFinite(a) && Math.abs(a - b) <= tolerance;
const sameBox = (a, b) => a && b && ['x', 'y', 'width', 'height'].every(k => near(a[k], b[k]));
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' };
await fs.mkdir(output, { recursive: true });
const previous = baselineMode || policiesOnly ? null : JSON.parse(await fs.readFile(path.join(output, 'baseline-browser.json'), 'utf8'));
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/__p32_link') return response.end('<!doctype html><link rel="icon" href="data:,"><p id="proof">Group link reached</p>');
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(directory, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
response.end(await fs.readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server);
return `http://127.0.0.1:${server.address().port}`;
}
async function ready(page) {
await page.waitForFunction(() => {
const s = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return s?.querySelector('.hm-orgchart-composition[data-display-mode="readOnly"] [data-card-asset]') && [...s.querySelectorAll('link[rel=stylesheet]')].every(l => l.sheet);
}, null, { timeout: 25000 });
await page.evaluate(async () => {
await document.fonts.load('600 18px "Noto Sans KR"', '홍 길 동');
await document.fonts.ready;
await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(i => i.decode().catch(() => {})));
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
});
}
async function measure(page) {
return page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot, stage = s.querySelector('.hm-orgchart-stage');
const origin = stage.getBoundingClientRect(), scale = origin.width / 1920;
const rect = e => { const r = e.getBoundingClientRect(); return { x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }; };
const relative = (r, c) => ({ x: r.x - c.x, y: r.y - c.y, width: r.width, height: r.height });
const style = (e, props) => Object.fromEntries(props.map(p => [p, getComputedStyle(e)[p]]));
const typography = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'letterSpacing'];
const contained = (a, b, tolerance = .5) => a.x >= b.x - tolerance && a.y >= b.y - tolerance && a.x + a.width <= b.x + b.width + tolerance && a.y + a.height <= b.y + b.height + tolerance;
const overlap = (a, b) => a && b && Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > .5 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > .5;
const inks = new Map();
// Reuse the approved P3 logo test method: natural PNG alpha bounds (>16),
// transformed through object-fit:contain. Transparent image padding is excluded.
const logoPixels = img => {
if (!img?.naturalWidth) return null;
if (!inks.has(img.src)) {
const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let left = canvas.width, right = -1, top = canvas.height, bottom = -1;
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) if (pixels[(y * canvas.width + x) * 4 + 3] > 16) {
left = Math.min(left, x); right = Math.max(right, x + 1); top = Math.min(top, y); bottom = Math.max(bottom, y + 1);
}
inks.set(img.src, { left, right, top, bottom });
}
const a = inks.get(img.src), b = rect(img), fit = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight);
const x = b.x + (b.width - img.naturalWidth * fit) / 2, y = b.y + (b.height - img.naturalHeight * fit) / 2;
return { x: x + a.left * fit, y: y + a.top * fit, width: (a.right - a.left) * fit, height: (a.bottom - a.top) * fit };
};
const textMeasure = e => {
const box = rect(e), r = document.createRange(); r.selectNodeContents(e);
const ranges = [...r.getClientRects()].filter(r => r.width > 0).map(r => ({ x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }));
return { text: e.textContent, box, ranges, clientWidth: e.clientWidth, scrollWidth: e.scrollWidth, ...style(e, typography) };
};
const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => {
const box = rect(c), status = c.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? null;
const avatar = rect(c.querySelector('.hm-orgchart-avatar')), mark = c.querySelector('.hm-orgchart-affiliation-mark'), img = mark?.querySelector('img');
const imageBox = img && rect(img), fit = img?.naturalWidth ? Math.min(imageBox.width / img.naturalWidth, imageBox.height / img.naturalHeight) : 0;
const contentBox = img ? { x: imageBox.x + (imageBox.width - img.naturalWidth * fit) / 2, y: imageBox.y + (imageBox.height - img.naturalHeight * fit) / 2, width: img.naturalWidth * fit, height: img.naturalHeight * fit } : null;
const logo = img ? { box: imageBox, contentBox, local: relative(imageBox, box), ink: logoPixels(img), naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, src: img.src, ...style(img, ['objectFit', 'filter', 'opacity', 'backgroundColor']) } : null;
const text = [...c.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-job-title')].map(textMeasure);
const collisions = [], outside = [];
if (!contained(avatar, box)) outside.push('avatar');
if (logo && !contained(logo.box, box)) outside.push('logo image box');
if (logo?.ink && overlap(logo.ink, avatar)) collisions.push('logo/avatar');
for (const t of text) {
if (t.scrollWidth > t.clientWidth + 1) outside.push(`${t.text}: horizontal overflow`);
for (const r of t.ranges) {
// Text Range includes font ascent/descent, and is conservative for logo
// and avatar tests. Adjacent Range-to-Range intersections are recorded
// separately; they are not treated as painted glyph collisions.
const visible = { x: Math.max(r.x, t.box.x), y: r.y, width: Math.max(0, Math.min(r.x + r.width, t.box.x + t.box.width) - Math.max(r.x, t.box.x)), height: r.height };
if (!contained(r, box)) outside.push(`${t.text}: text Range outside card`);
if (overlap(visible, avatar)) collisions.push(`${t.text}/avatar`);
if (logo?.ink && overlap(visible, logo.ink)) collisions.push(`${t.text}/logo ink`);
}
}
const fontRangeOverlaps = [];
for (const [i, a] of text.entries()) for (const b of text.slice(i + 1)) for (const ar of a.ranges) for (const br of b.ranges) if (overlap(ar, br)) fontRangeOverlaps.push([a.text, b.text]);
const name = textMeasure(c.querySelector('.hm-orgchart-person-name')), role = textMeasure(c.querySelector('.hm-orgchart-person-role'));
const photo = c.querySelector('.hm-orgchart-avatar img');
return { key: `${status ?? 'tree'}:${c.dataset.employeeId}`, id: c.dataset.employeeId, status, size: c.dataset.cardAssetSize ?? null, kind: c.dataset.cardKind, box,
name, role, badge: c.querySelector('.hm-orgchart-job-title')?.textContent ?? '', statusLabel: c.querySelector('.hm-orgchart-status-label')?.textContent ?? '',
avatar: { box: avatar, local: relative(avatar, box), photoState: c.dataset.photoState, placeholder: photo.dataset.photoPlaceholder, naturalWidth: photo.naturalWidth },
nameLocal: relative(name.box, box), roleLocal: relative(role.box, box), text, logo, mark: mark ? { box: rect(mark), ...style(mark, ['backgroundColor', 'border', 'boxShadow']) } : null,
surface: style(c, ['backgroundImage', 'border', 'borderRadius', 'padding', 'columnGap']), collisions, outside, fontRangeOverlaps };
});
const headers = [...s.querySelectorAll('.node[data-organization-id]')].map(n => {
const h = n.querySelector('.hm-orgchart-organization-name'), first = n.querySelector('.hm-orgchart-employee');
return { id: n.dataset.organizationId, depth: Number(n.dataset.organizationDepth), box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, typography) };
});
const statusHeaders = [...s.querySelectorAll('.hm-orgchart-status-group')].map(g => {
const h = g.querySelector('.hm-orgchart-summary-title'), first = g.querySelector('.hm-orgchart-employee');
return { status: g.dataset.workStatus, text: h.textContent, box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, [...typography, 'backgroundImage']) };
});
const footer = s.querySelector('.hm-orgchart-affiliation-bar'), table = s.querySelector('.hm-orgchart-workdesk-position-table');
return { scale, cards, headers, statusHeaders,
footer: { box: rect(footer), text: footer.textContent, logos: [...footer.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(img => ({ id: img.parentElement.dataset.affiliationId, box: rect(img), ...style(img, ['width', 'height']) })) },
table: { box: rect(table), text: table.textContent, total: table.querySelector('[data-total-count]').textContent },
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
policies: { displayMode: s.querySelector('.hm-orgchart-composition').dataset.displayMode, collapseButtons: s.querySelectorAll('.hm-orgchart-toggle').length, connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length, arrows: s.querySelectorAll('.hm-orgchart-workdesk-overlay [data-arrow]').length, background: getComputedStyle(s.querySelector('.hm-orgchart-composition')).backgroundColor },
stage: rect(stage) };
});
}
async function measurePolicies(page) {
return page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
const composition = s.querySelector('.hm-orgchart-composition');
return { displayMode: composition.dataset.displayMode, collapseButtons: s.querySelectorAll('.hm-orgchart-toggle').length,
connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length,
arrows: s.querySelectorAll('.hm-orgchart-workdesk-overlay [data-arrow]').length,
background: getComputedStyle(composition).backgroundColor };
});
}
function verify(m, before, route, viewport) {
const label = `${route}/${viewport.width}×${viewport.height}`;
check(m.cards.length === before.cards.length && m.cards.every(c => before.cards.some(b => b.key === c.key)), `${label}: same employee IDs and rendered count`);
check(m.cards.every(c => c.name.text === '홍 길 동' && !/\s/u.test(c.role.text) && !/\s/u.test(c.badge) && c.name.fontFamily.includes('Noto Sans KR') && c.role.fontFamily.includes('Noto Sans KR')), `${label}: Noto Sans KR, spaced names and compact position/badge text`);
check(m.cards.every(c => {
const b = before.cards.find(b => b.key === c.key);
const preserved = ['fontWeight', 'lineHeight', 'color', 'letterSpacing'];
if (c.status) return c.name.fontSize === '18px' && c.role.fontSize === '15px';
if (c.size === 'representative') return [...preserved, 'fontSize'].every(p => c.name[p] === b.name[p] && c.role[p] === b.role[p]);
return c.name.fontSize === (c.size === 'leader' ? '24px' : '18px') && c.role.fontSize === (c.size === 'leader' ? '18px' : '15px') && preserved.every(p => c.name[p] === b.name[p] && c.role[p] === b.role[p]);
}), `${label}: requested card typography; representative and other approved properties preserved`);
const general = m.headers.find(h => h.id === 'integrated-system-1');
check(general && m.statusHeaders.every(h => ['fontSize', 'fontWeight', 'lineHeight'].every(p => h[p] === general[p]) && h.backgroundImage === before.statusHeaders.find(b => b.status === h.status).backgroundImage), `${label}: status title equals standard 2Depth typography; gray gradient retained`, m.statusHeaders);
const normal = m.cards.find(c => !c.status && c.size === 'employee' && c.kind === 'employee' && !c.badge);
const common = ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'];
check(normal && m.cards.filter(c => c.status).every(c => sameBox(c.avatar.local, normal.avatar.local) && sameBox(c.nameLocal, normal.nameLocal) && sameBox(c.roleLocal, normal.roleLocal) && common.every(p => c.name[p] === normal.name[p] && c.role[p] === normal.role[p]) && c.statusLabel === '' && c.name.color === before.cards.find(b => b.key === c.key).name.color && c.role.color === before.cards.find(b => b.key === c.key).role.color), `${label}: status photo/text geometry and typography equals compact cards; own colors retained; no status line`, m.cards.filter(c => c.status).map(c => ({ key: c.key, avatar: c.avatar.local, name: c.nameLocal, role: c.roleLocal })));
check(m.cards.filter(c => c.status).every(c => { const b = before.cards.find(b => b.key === c.key); return ['backgroundImage', 'border', 'borderRadius'].every(p => c.surface[p] === b.surface[p]) && c.logo.filter === b.logo.filter && c.logo.opacity === b.logo.opacity; }), `${label}: status card surface and image-only grayscale/opacity retained`);
check(m.cards.every(c => { const [w, h] = c.size === 'representative' ? [146, 74] : c.size === 'leader' ? [146, 68] : [108, 52]; return near(c.box.width, w) && near(c.box.height, h); }), `${label}: card sizes remain 146×74/146×68/108×52; status corrected to requested108×52`);
check([...m.headers, ...m.statusHeaders].every(h => h.gap === null || near(h.gap, 8)), `${label}: every title↔first card gap8px`);
check(m.cards.every(c => c.logo && c.logo.naturalWidth && c.logo.objectFit === 'contain' && c.logo.src === before.cards.find(b => b.key === c.key).logo?.src && ['width', 'height'].every(p => { const old = before.cards.find(b => b.key === c.key).logo; const fitted = old.contentBox ?? { width: old.naturalWidth * Math.min(old.box.width / old.naturalWidth, old.box.height / old.naturalHeight), height: old.naturalHeight * Math.min(old.box.width / old.naturalWidth, old.box.height / old.naturalHeight) }; return near(c.logo.contentBox[p] / fitted[p], 1.5, .005); }) && ['width', 'height'].every(p => near(c.logo.ink[p] / before.cards.find(b => b.key === c.key).logo.ink[p], 1.5, .005))), `${label}: all actual card logo contained images and painted ink scale1.5×; assets/ratio retained`, m.cards.map(c => ({ key: c.key, image: c.logo?.box, fitted: c.logo?.contentBox, ink: c.logo?.ink })));
check(m.cards.every(c => c.mark.backgroundColor === 'rgba(0, 0, 0, 0)' && c.mark.border.startsWith('0px none') && c.mark.boxShadow === 'none'), `${label}: card logos have no added frame/background/shadow`);
check(m.cards.every(c => !c.collisions.length && !c.outside.length), `${label}: no logo/avatar/visible text overlap or clipping`, m.cards.filter(c => c.collisions.length || c.outside.length).map(c => ({ key: c.key, collisions: c.collisions, outside: c.outside })));
check(m.cards.every(c => c.avatar.photoState === 'placeholder' && c.avatar.placeholder === 'true' && c.avatar.naturalWidth > 0 && c.badge !== '경영진'), `${label}: common photo placeholder and no executive badge`);
check(m.footer.text === before.footer.text && sameBox(m.footer.box, before.footer.box) && m.footer.logos.length === before.footer.logos.length && m.footer.logos.every((l, i) => sameBox(l.box, before.footer.logos[i].box)), `${label}: footer counts/geometry/logos unchanged`);
check(m.table.text === before.table.text && m.table.total === '41' && sameBox(m.table.box, before.table.box) && m.policies.displayMode === 'readOnly' && m.policies.collapseButtons === 0 && m.policies.connectorPaths === before.policies.connectorPaths && m.policies.arrows === 5 && m.policies.background === 'rgb(245, 248, 251)', `${label}: readOnly/background/connectors/statistics policies retained`, m.policies);
check(m.document.scrollWidth === m.document.clientWidth && m.document.scrollHeight === m.document.clientHeight && m.cards.every(c => c.box.x >= -.5 && c.box.y >= -.5 && c.box.x + c.box.width <= 1920.5 && c.box.y + c.box.height <= 915), `${label}: no page scroll; cards contained in scaled stage`, m.document);
}
const dialogTargets = [['representative', '.hm-orgchart-top-leaders .hm-orgchart-employee'], ['depth1', '.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'], ['small', '.hm-orgchart-engine .hm-orgchart-employee[data-card-asset-size="employee"]'], ['dispatched', '.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'], ['onLeave', '.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee']];
async function dialogs(page, route, viewport) {
await page.setViewportSize(viewport); await ready(page);
for (const [kind, selector] of dialogTargets) {
const card = page.locator(selector).first(), dialog = page.locator('dialog[open]'), id = await card.getAttribute('data-employee-id');
await card.click(); await dialog.waitFor({ state: 'visible' });
const value = await dialog.evaluate(d => ({ fields: Object.fromEntries([...d.querySelectorAll('dt')].map(t => [t.textContent, t.nextElementSibling.textContent])), scaled: !!d.closest('.hm-orgchart-stage'), transform: getComputedStyle(d).transform, width: d.getBoundingClientRect().width }));
const employee = fixture.employees.find(e => e.id === id);
check(value.fields['이름'] === '홍 길 동' && value.fields['직위'] === employee.position && (!employee.jobTitle || value.fields['직책'] === employee.jobTitle) && !value.scaled && value.transform === 'none' && near(value.width, Math.min(420, viewport.width - 32)), `${route}/${viewport.width}: ${kind} popup retains data/unscaled size`, value);
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
check(await dialog.evaluate(d => d.contains(d.getRootNode().activeElement)), `${route}/${viewport.width}: ${kind} popup focus trap`);
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' });
check(await card.evaluate(e => e.getRootNode().activeElement === e), `${route}/${viewport.width}: ${kind} ESC focus return`);
}
}
async function links(page, route, origin) {
const id = fixture.organizations.find(o => o.parentId === fixture.organizations.find(o => o.parentId === null).id).id;
const configured = { [id]: `${origin}/__p32_link` };
await page.evaluate(async ({ configured, transport }) => {
if (transport === 'react') window.OrgChartP1React.setOptions({ organizationLinks: configured });
else { const s = window.OrgChartP1; s.chart.destroy(); s.chart = s.api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new s.api.JsonOrgChartDataSource(s.data), stylesheetUrls: s.stylesheetUrls, displayMode: 'readOnly', organizationLinks: configured }); await s.chart.mount(); }
}, { configured, transport: route });
await ready(page);
const link = page.locator('.hm-orgchart-organization-link'); check(await link.count() === 1, `${route}: only configured 1Depth title becomes link`);
await link.focus(); await Promise.all([page.waitForURL(configured[id]), page.keyboard.press('Enter')]);
check(await page.locator('#proof').textContent() === 'Group link reached', `${route}: real1Depth keyboard link navigation`);
}
let browser;
try {
const htmlOrigin = await serve(path.join(root, 'examples/org-chart/dist'));
const reactOrigin = await serve(path.join(root, 'examples/org-chart-react/dist'));
browser = await launchChromium({ headless: true });
const context = await browser.newContext();
const routes = [{ name: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly`, origin: htmlOrigin }, { name: 'file-classic', url: pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href + '?mode=readOnly' }, { name: 'react', url: `${reactOrigin}/p1.html?mode=readOnly`, origin: reactOrigin }, { name: 'storybook', url: `${storyOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` }];
for (const route of routes) {
const page = await context.newPage();
page.on('pageerror', e => errors.push({ route: route.name, type: 'runtime', message: e.message }));
page.on('console', e => { if (e.type() === 'error') errors.push({ route: route.name, type: 'console', message: e.text() }); });
try {
await page.goto(route.url); await ready(page);
if (policiesOnly) {
await page.setViewportSize(viewports[0]); await ready(page);
const policies = await measurePolicies(page);
measurements.push({ route: route.name, ...viewports[0], policies });
check(policies.displayMode === 'readOnly' && policies.collapseButtons === 0, `${route.name}: readOnly has no actual hm-orgchart-toggle controls`, policies);
check(policies.background === 'rgb(245, 248, 251)', `${route.name}: actual composition background is #F5F8FB`, policies.background);
check(policies.connectorPaths === 5 && policies.arrows === 5, `${route.name}: actual overlay retains five connector paths and five collaboration arrows`, policies);
} else {
for (const viewport of viewports) {
await page.setViewportSize(viewport); await ready(page);
const m = await measure(page); measurements.push({ route: route.name, ...viewport, ...m });
if (!baselineMode) verify(m, previous.measurements.find(b => b.route === route.name && b.width === viewport.width), route.name, viewport);
if ([1920, 390].includes(viewport.width)) await page.screenshot({ path: path.join(output, `${baselineMode ? 'baseline-' : ''}${route.name}-${viewport.width}x${viewport.height}.png`) });
}
if (!baselineMode) {
await dialogs(page, route.name, viewports[0]); await dialogs(page, route.name, viewports.at(-1));
if (['html', 'react'].includes(route.name)) await links(page, route.name, route.origin);
}
}
} catch (error) { check(false, `${route.name}: focused browser capture completed`, { message: error.message, stack: error.stack }); }
await page.close();
}
if (policiesOnly) check(!errors.length, 'no new console/runtime errors during policy-only capture', errors);
else if (!baselineMode) {
for (const viewport of viewports) {
const variants = measurements.filter(m => m.width === viewport.width), base = variants.find(m => m.route === 'html');
check(variants.length === 4 && variants.every(m => m.cards.length === base.cards.length && m.cards.every((c, i) => sameBox(c.box, base.cards[i].box) && sameBox(c.nameLocal, base.cards[i].nameLocal) && sameBox(c.roleLocal, base.cards[i].roleLocal) && c.name.fontSize === base.cards[i].name.fontSize && c.role.fontSize === base.cards[i].role.fontSize && sameBox(c.logo.box, base.cards[i].logo.box))), `HTML/classic/React/Storybook same card typography/geometry/${viewport.width}`);
}
const index = await fetch(`${storyOrigin}/index.json`).then(r => r.json());
const stories = Object.values(index.entries).filter(e => e.title === 'OrgChart' && e.type === 'story' && !e.tags?.includes('internal'));
check(stories.length === 3, 'existing Storybook three user menus retained', stories.map(e => e.name));
check(!errors.length, 'no new console/runtime errors', errors);
}
} finally {
await browser?.close(); for (const server of servers) await new Promise(resolve => server.close(resolve));
const summary = { total: results.length, pass: results.filter(r => r.status === 'PASS').length, fail: results.filter(r => r.status === 'FAIL').length };
const report = { scope: policiesOnly ? 'P3.2 measurement-selector correction only; retain browser.json and do not repeat passed typography/logo/layout/dialog/link checks' : 'P3.2 changes only; prior P3.1 486 checks retained without rerun', baselineMode, policiesOnly, fixtureSha256: createHash('sha256').update(fixtureBytes).digest('hex'), summary, results, measurements, errors,
collisionMethod: 'Natural PNG alpha bounds >16 transformed through object-fit contain, versus visible text Range and avatar rectangles. Adjacent font Range envelopes are recorded only; painted font glyph interpenetration is not certified by this test.' };
await fs.writeFile(path.join(output, policiesOnly ? 'policy-browser.json' : baselineMode ? 'baseline-browser.json' : 'browser.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ baselineMode, policiesOnly, captures: measurements.length, summary, errors, failures: results.filter(r => r.status === 'FAIL').map(r => r.name) }, null, 2));
if (summary.fail || (baselineMode && measurements.length !== routesLength())) process.exitCode = 1;
}
function routesLength() { return 4 * viewports.length; }