437 lines
47 KiB
JavaScript
437 lines
47 KiB
JavaScript
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 { createHash } from 'node:crypto';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
|
||
// P3 impact checks only. P2's 484 checks and every earlier report are retained.
|
||
// Runtime layout, accessibility interaction, and data aggregation are measured;
|
||
// font ink and curved connector routing still require saved-image review.
|
||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||
const output = path.join(root, 'test-results/org-chart-p3');
|
||
const fixturePath = path.join(root, 'examples/org-chart/data-p1.json');
|
||
const fixtureBytes = await readFile(fixturePath);
|
||
const fixtureHash = createHash('sha256').update(fixtureBytes).digest('hex');
|
||
const fixture = JSON.parse(fixtureBytes.toString('utf8'));
|
||
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 results = [], measurements = [], interactions = [], errors = [], screenshots = [], servers = [];
|
||
const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
|
||
const near = (actual, expected, tolerance = 0.25) => Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance;
|
||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' };
|
||
await mkdir(output, { recursive: true });
|
||
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 === '/__p3_link_proof') {
|
||
response.setHeader('Content-Type', 'text/html');
|
||
return response.end('<!doctype html><title>P3 verifier navigation proof</title><link rel="icon" href="data:,"><p id="proof">Configured organization 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 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}`;
|
||
}
|
||
function captureErrors(page, route) {
|
||
page.on('pageerror', error => errors.push({ route, type: 'pageerror', message: error.message }));
|
||
page.on('console', message => { if (message.type() === 'error') errors.push({ route, type: 'console', message: message.text() }); });
|
||
}
|
||
async function ready(page, mode = 'readOnly') {
|
||
await page.waitForFunction(expectedMode => {
|
||
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||
return shadow?.querySelector(`.hm-orgchart-composition[data-display-mode="${expectedMode}"] [data-card-asset]`)
|
||
&& !shadow.querySelector('.hm-orgchart-state')?.textContent
|
||
&& [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet);
|
||
}, mode, { timeout: 20000 });
|
||
await page.evaluate(async () => {
|
||
await document.fonts.ready;
|
||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||
await Promise.all([...shadow.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
|
||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
});
|
||
}
|
||
async function measure(page) {
|
||
return page.evaluate(() => {
|
||
const host = document.querySelector('.hm-orgchart-host'), shadow = host.shadowRoot;
|
||
// Chrome can serialize a zero letter-spacing as "normal". Compare against
|
||
// an independently authored zero-spacing probe, not a guessed string.
|
||
const zeroSpacingProbe = document.createElement('span');
|
||
zeroSpacingProbe.textContent = '홍 길 동';
|
||
zeroSpacingProbe.style.letterSpacing = '0';
|
||
shadow.append(zeroSpacingProbe);
|
||
const zeroSpacingSerialization = { authored: zeroSpacingProbe.style.letterSpacing, computed: getComputedStyle(zeroSpacingProbe).letterSpacing };
|
||
zeroSpacingProbe.remove();
|
||
const composition = shadow.querySelector('.hm-orgchart-composition'), stage = shadow.querySelector('.hm-orgchart-stage');
|
||
const origin = stage.getBoundingClientRect(), scale = origin.width / stage.offsetWidth;
|
||
const rect = element => {
|
||
if (!element) return null;
|
||
const box = element.getBoundingClientRect();
|
||
return { x: (box.left - origin.left) / scale, y: (box.top - origin.top) / scale, width: box.width / scale, height: box.height / scale };
|
||
};
|
||
const screenRect = element => {
|
||
const box = element.getBoundingClientRect();
|
||
return { x: box.x, y: box.y, width: box.width, height: box.height, right: box.right, bottom: box.bottom };
|
||
};
|
||
const dimensions = element => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY });
|
||
const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
|
||
const overlaps = (a, b) => Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.75 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.75;
|
||
const contained = (inner, outer, tolerance = 0.75) => inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.x + inner.width <= outer.x + outer.width + tolerance && inner.y + inner.height <= outer.y + outer.height + tolerance;
|
||
const cssValues = (element, properties) => element ? Object.fromEntries(properties.map(name => [name, getComputedStyle(element)[name]])) : null;
|
||
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => {
|
||
const node = card.closest('.node'), box = rect(card), name = card.querySelector('.hm-orgchart-person-name');
|
||
const role = card.querySelector('.hm-orgchart-person-role'), badge = card.querySelector('.hm-orgchart-job-title');
|
||
const row = card.querySelector('.hm-orgchart-position-row'), avatar = card.querySelector('.hm-orgchart-avatar');
|
||
const textBoxes = [...card.querySelectorAll('.hm-orgchart-person-role, .hm-orgchart-person-name, .hm-orgchart-job-title')].map(element => {
|
||
const range = document.createRange(); range.selectNodeContents(element);
|
||
const envelopes = [...range.getClientRects()].map(rangeBox => ({ x: (rangeBox.x - origin.left) / scale, y: (rangeBox.y - origin.top) / scale, width: rangeBox.width / scale, height: rangeBox.height / scale }));
|
||
return { className: element.className, box: rect(element), clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, envelopes };
|
||
});
|
||
const issues = textBoxes.flatMap(item => [
|
||
...(item.scrollWidth > item.clientWidth + 1 ? [`${item.className}: horizontal text overflow`] : []),
|
||
...(!contained(item.box, box) ? [`${item.className}: element outside card`] : []),
|
||
...(item.envelopes.some(envelope => envelope.x < box.x - 0.75 || envelope.x + envelope.width > box.x + box.width + 0.75) ? [`${item.className}: text outside horizontal card bounds`] : []),
|
||
]);
|
||
const logo = card.querySelector('.hm-orgchart-affiliation-mark');
|
||
const avatarBox = rect(avatar), logoBox = rect(logo);
|
||
for (const item of textBoxes) {
|
||
if (overlaps(item.box, avatarBox)) issues.push(`${item.className}: avatar element overlap`);
|
||
if (logoBox && item.envelopes.some(envelope => overlaps(envelope, logoBox))) issues.push(`${item.className}: text envelope/logo overlap`);
|
||
}
|
||
return {
|
||
id: card.dataset.employeeId, kind: card.dataset.cardKind, asset: card.dataset.cardAsset ?? null, size: card.dataset.cardAssetSize ?? null,
|
||
organizationId: node?.dataset.organizationId ?? null, depth: node ? Number(node.dataset.organizationDepth ?? node.dataset.depth) : null,
|
||
statusGroup: card.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? null, box,
|
||
name: text(name), role: text(role), badge: text(badge), nameAccessible: name.getAttribute('aria-label'),
|
||
photoState: card.dataset.photoState, avatarText: text(avatar), avatarBox, avatarStyle: cssValues(avatar, ['borderRadius', 'backgroundColor']),
|
||
photoStyle: cssValues(avatar.querySelector('img'), ['objectFit']),
|
||
rowGap: row ? rect(name).y - rect(row).y - rect(row).height : null,
|
||
badgeGap: row && badge ? rect(badge).x - rect(role).x - rect(role).width : null,
|
||
badgeInRow: !badge || !!badge.closest('.hm-orgchart-position-row'),
|
||
badgeStyle: cssValues(badge, ['color', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderRadius', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'paddingLeft', 'paddingRight']),
|
||
nameStyle: cssValues(name, ['fontSize', 'fontWeight', 'lineHeight']),
|
||
roleStyle: cssValues(role, ['fontSize', 'fontWeight', 'lineHeight']),
|
||
statusLabels: card.querySelectorAll('.hm-orgchart-status-label').length,
|
||
keyboardRole: card.getAttribute('role'), tabIndex: card.tabIndex, issues, textBoxes,
|
||
};
|
||
});
|
||
const headers = [...shadow.querySelectorAll('.node[data-organization-id] .hm-orgchart-organization-name')].map(header => {
|
||
const node = header.closest('.node'), box = rect(header), first = node.querySelector('.hm-orgchart-employee');
|
||
return { id: node.dataset.organizationId, depth: Number(node.dataset.organizationDepth ?? node.dataset.depth), text: text(header), box,
|
||
gap: first ? rect(first).y - box.y - box.height : null,
|
||
style: cssValues(header, ['backgroundImage', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'textAlign', 'padding', 'borderRadius', 'boxShadow']), link: header.querySelector('a')?.getAttribute('href') ?? null };
|
||
});
|
||
const pseudoRoutes = [...shadow.querySelectorAll('.hm-orgchart-engine .hierarchy, .hm-orgchart-engine .node')].flatMap(element => ['::before', '::after'].map(pseudo => ({ className: element.className, depth: element.dataset.depth ?? null, pseudo, content: getComputedStyle(element, pseudo).content, display: getComputedStyle(element, pseudo).display })));
|
||
const overlay = shadow.querySelector('.hm-orgchart-workdesk-overlay');
|
||
const connectors = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay path[data-connector]')].map(element => ({ id: element.dataset.connector, path: element.getAttribute('d') }));
|
||
const arrows = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay image[data-arrow]')].map(element => ({ id: element.dataset.arrow, href: (element.getAttribute('href') ?? '').slice(0, 40), box: rect(element) }));
|
||
const table = shadow.querySelector('.hm-orgchart-workdesk-position-table');
|
||
const tableData = table ? {
|
||
title: text(shadow.querySelector('.hm-orgchart-organization-totals-title')),
|
||
header: [...table.querySelectorAll('thead th')].map(text),
|
||
rows: [...table.querySelectorAll('tbody tr')].map(row => ({ id: row.dataset.organizationId, scope: row.dataset.countScope, values: [...row.children].map(text) })),
|
||
total: [...table.querySelectorAll('tfoot tr > *')].map(text), box: rect(table),
|
||
} : null;
|
||
const hub = shadow.querySelector('.hm-orgchart-hub'), hubIcon = hub?.querySelector('.hm-orgchart-hub-icon'), hubLabel = hub?.querySelector('.hm-orgchart-hub-label');
|
||
const bar = shadow.querySelector('.hm-orgchart-affiliation-bar');
|
||
const barItems = [...shadow.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ id: item.dataset.affiliationId ?? 'total', count: Number(item.dataset.employeeCount ?? item.dataset.totalCount), box: rect(item), logoLoaded: [...item.querySelectorAll('img')].every(image => image.complete && image.naturalWidth > 0) }));
|
||
const modules = [...shadow.querySelectorAll('.hm-orgchart-organization-totals, .hm-orgchart-affiliation-bar, .hm-orgchart-hub')].map(element => ({ id: element.className, box: rect(element) }));
|
||
const physical = [...cards.map(card => ({ id: `card:${card.id}:${card.statusGroup ?? 'tree'}`, box: card.box })), ...headers.map(header => ({ id: `header:${header.id}`, box: header.box }))];
|
||
const collisions = [];
|
||
for (const [index, a] of physical.entries()) for (const b of physical.slice(index + 1)) if (overlaps(a.box, b.box)) collisions.push({ a: a.id, b: b.id });
|
||
const moduleCollisions = modules.flatMap(module => physical.filter(item => overlaps(module.box, item.box)).map(item => ({ module: module.id, item: item.id })));
|
||
for (const [index, a] of modules.entries()) for (const b of modules.slice(index + 1)) if (overlaps(a.box, b.box)) moduleCollisions.push({ a: a.id, b: b.id });
|
||
const outsideStage = [...physical, ...modules].filter(item => !contained(item.box, { x: 0, y: 0, width: stage.offsetWidth, height: stage.offsetHeight })).map(item => item.id);
|
||
return {
|
||
scale, recordedScale: composition.dataset.scale, baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, zeroSpacingSerialization,
|
||
screen: { stage: screenRect(stage), composition: screenRect(composition), host: screenRect(host) },
|
||
background: { body: getComputedStyle(document.body).backgroundColor, composition: getComputedStyle(composition).backgroundColor, stage: getComputedStyle(stage).backgroundColor },
|
||
document: dimensions(document.documentElement), composition: dimensions(composition), stage: dimensions(stage), host: dimensions(host),
|
||
cards, headers, pseudoRoutes, connectors, arrows, overlayViewBox: overlay?.getAttribute('viewBox'),
|
||
decorations: [...shadow.querySelectorAll('img[data-workdesk-asset]')].map(image => ({ id: image.dataset.workdeskAsset, loaded: image.complete && image.naturalWidth > 0 })),
|
||
table: tableData, bar: bar ? { box: rect(bar), titleCount: bar.querySelectorAll('h1,h2,h3').length, items: barItems } : null,
|
||
hub: { box: rect(hub), iconBox: rect(hubIcon), labelBox: rect(hubLabel), paths: hubIcon?.querySelectorAll('path').length ?? 0,
|
||
gap: hubLabel && hubIcon ? rect(hubLabel).x - rect(hubIcon).x - rect(hubIcon).width : null,
|
||
style: cssValues(hubLabel, ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']), text: text(hubLabel) },
|
||
status: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(group => ({ status: group.dataset.workStatus, heading: text(group.querySelector('h3')) })),
|
||
collisions, moduleCollisions, outsideStage, debugCount: shadow.querySelectorAll('.hm-orgchart-toggle, .hm-orgchart-kind, .hm-orgchart-domain-id, .hm-orgchart-node-count, .hm-orgchart-vacancy, .hm-orgchart-description, .hm-orgchart-policy').length,
|
||
};
|
||
});
|
||
}
|
||
|
||
const preferredPositions = ['경영진', '수석', '책임', '선임', '연구'];
|
||
const positions = [...preferredPositions.filter(position => fixture.employees.some(employee => employee.position === position)), ...new Set(fixture.employees.map(employee => employee.position).filter(position => !preferredPositions.includes(position)))];
|
||
const organizationById = new Map(fixture.organizations.map(organization => [organization.id, organization]));
|
||
const depthOf = id => { let depth = 0; while (organizationById.get(id)?.parentId) { id = organizationById.get(id).parentId; depth++; } return depth; };
|
||
const majorRows = fixture.organizations.filter(organization => depthOf(organization.id) <= 1);
|
||
const descendantsOf = id => { const ids = [id]; for (let index = 0; index < ids.length; index++) ids.push(...fixture.organizations.filter(organization => organization.parentId === ids[index]).map(organization => organization.id)); return ids; };
|
||
const expectedRows = majorRows.map(organization => {
|
||
const scope = organization.parentId === null && fixture.organizations.some(item => item.parentId === organization.id) ? 'direct' : 'inclusive';
|
||
const ids = scope === 'direct' ? [organization.id] : descendantsOf(organization.id);
|
||
const employees = fixture.employees.filter(employee => ids.includes(employee.organizationId));
|
||
return { id: organization.id, scope, values: [organization.name, ...positions.map(position => String(employees.filter(employee => employee.position === position).length)), String(employees.length)] };
|
||
}).filter(row => row.scope !== 'direct' || Number(row.values.at(-1)) > 0);
|
||
const expectedTotal = ['계', ...positions.map(position => String(fixture.employees.filter(employee => employee.position === position).length)), String(fixture.employees.length)];
|
||
function verify(value, label, viewport) {
|
||
const assetCards = value.cards.filter(card => card.asset);
|
||
const first = assetCards.filter(card => card.depth === 1 && card.kind === 'leader');
|
||
const smaller = assetCards.filter(card => card.depth >= 2 || card.kind === 'employee');
|
||
check(value.background.body === 'rgb(245, 248, 251)' && value.background.composition === 'rgb(245, 248, 251)' && value.background.stage === 'rgb(245, 248, 251)', `${label}: Full Composition background #F5F8FB`, value.background);
|
||
check(value.pseudoRoutes.every(pseudo => ['none', 'normal'].includes(pseudo.content) || pseudo.display === 'none'), `${label}: every engine hierarchy/node pseudo connector is hidden`, value.pseudoRoutes.filter(pseudo => !['none', 'normal'].includes(pseudo.content) && pseudo.display !== 'none'));
|
||
check(value.connectors.length === 5 && value.connectors.every(connector => connector.path) && value.arrows.length === 5 && value.overlayViewBox === '0 0 1920 914.5', `${label}: existing five Workdesk curve paths and five collaboration arrow assets retained`, { connectors: value.connectors, arrows: value.arrows.map(arrow => arrow.id) });
|
||
check(value.decorations.length > 0 && value.decorations.every(image => image.loaded), `${label}: original Workdesk decoration assets remain loaded`, value.decorations);
|
||
check(value.cards.every(card => card.name === '홍 길 동' && card.nameAccessible === '홍길동' && card.role === fixture.employees.find(employee => employee.id === card.id)?.position && card.badge === (fixture.employees.find(employee => employee.id === card.id)?.jobTitle ?? '')), `${label}: only names are spaced; positions and badges preserve raw strings`, value.cards.map(({ id, name, role, badge }) => ({ id, name, role, badge })));
|
||
check(assetCards.every(card => near(card.rowGap, 0) && card.badgeInRow && (card.badgeGap === null || near(card.badgeGap, 4))), `${label}: role and badge share one row, gap4; position/name gap0`, assetCards.map(({ id, rowGap, badgeGap, badgeInRow }) => ({ id, rowGap, badgeGap, badgeInRow })));
|
||
check(value.zeroSpacingSerialization.authored === '0px' && assetCards.filter(card => card.badge).every(card => {
|
||
const small = card.size === 'employee', style = card.badgeStyle;
|
||
return style.borderTopWidth === '1px' && style.borderRadius === '4px' && style.letterSpacing === value.zeroSpacingSerialization.computed && style.fontSize === (small ? '8.5px' : '9px') && style.fontWeight === (small ? '600' : '700') && style.lineHeight === (small ? '8.5px' : '9px') && style.paddingLeft === (small ? '2px' : '3px') && style.paddingRight === (small ? '2px' : '3px');
|
||
}), `${label}: badge source border/radius/font restored`, { zeroSpacingSerialization: value.zeroSpacingSerialization, badges: assetCards.filter(card => card.badge).map(({ id, badgeStyle }) => ({ id, badgeStyle })) });
|
||
const tagColors = { 'business-planning': 'rgb(37, 78, 126)', 'talent-growth': 'rgb(14, 103, 200)', 'integrated-system': 'rgb(110, 71, 160)', 'design-planning': 'rgb(6, 154, 149)', 'solution-sales': 'rgb(81, 139, 53)' };
|
||
check(assetCards.filter(card => card.badge).every(card => card.badgeStyle.color === (tagColors[card.asset] ?? 'rgb(14, 84, 146)')), `${label}: organization-specific badge source colors`, assetCards.filter(card => card.badge).map(({ id, asset, badgeStyle }) => ({ id, asset, color: badgeStyle.color })));
|
||
check(assetCards.filter(card => card.badge).every(card => {
|
||
const color = tagColors[card.asset] ?? 'rgb(14, 84, 146)', alpha = card.asset in tagColors ? ['0.1', '0.26'] : ['0.08', '0.2'];
|
||
const rgba = value => color.replace('rgb(', 'rgba(').replace(')', `, ${value})`);
|
||
return card.badgeStyle.backgroundColor === rgba(alpha[0]) && card.badgeStyle.borderTopColor === rgba(alpha[1]);
|
||
}), `${label}: badge source background and border tint`, assetCards.filter(card => card.badge).map(({ id, badgeStyle }) => ({ id, background: badgeStyle.backgroundColor, border: badgeStyle.borderTopColor })));
|
||
check(value.cards.every(card => card.avatarStyle.borderRadius === '3.5px' && card.avatarText === '' && card.photoState === 'empty'), `${label}: no-photo state is empty rectangular slot with no circular avatar or copied photo`, value.cards.map(({ id, avatarStyle, avatarText, photoState }) => ({ id, avatarStyle, avatarText, photoState })));
|
||
check(assetCards.some(card => card.kind === 'topLeader' && near(card.box.width, 146) && near(card.box.height, 74)) && first.length === 6 && first.every(card => near(card.box.width, 146) && near(card.box.height, 68)) && smaller.length > 0 && smaller.every(card => near(card.box.width, 108) && near(card.box.height, 52)), `${label}: approved representative/Depth1/deeper card sizes preserved`);
|
||
check(value.headers.filter(header => header.gap !== null).every(header => near(header.gap, 8)), `${label}: every organization header-to-first-card gap8`, value.headers.map(({ id, depth, gap }) => ({ id, depth, gap })));
|
||
check(value.headers.filter(header => header.depth >= 2).every(header => near(header.box.width, 114) && near(header.box.height, 30.4) && header.style.backgroundImage.startsWith('linear-gradient') && header.style.borderRadius === '7.1px' && header.style.textAlign === 'center'), `${label}: deeper group source header geometry and gradient`, value.headers.filter(header => header.depth >= 2));
|
||
const headerGradients = { 'business-management': ['rgb(25, 77, 130)', 'rgb(29, 57, 92)'], 'business-planning': ['rgb(33, 100, 168)', 'rgb(37, 73, 117)'], 'talent-growth': ['rgb(15, 106, 206)', 'rgb(4, 74, 148)'], 'integrated-system': ['rgb(140, 103, 182)', 'rgb(93, 52, 148)'], 'design-planning': ['rgb(9, 167, 162)', 'rgb(0, 132, 127)'], 'solution-sales': ['rgb(90, 150, 60)', 'rgb(63, 118, 41)'] };
|
||
check(value.headers.filter(header => header.depth >= 2).every(header => {
|
||
let id = header.id; while (depthOf(id) > 1) id = organizationById.get(id).parentId;
|
||
const expected = headerGradients[id], hr = id === 'talent-growth';
|
||
return expected?.every(color => header.style.backgroundImage.includes(color)) && header.style.fontSize === (hr ? '18px' : '20px') && header.style.fontWeight === (hr ? '600' : '700') && header.style.lineHeight === (hr ? '16.9px' : '19.7px');
|
||
}), `${label}: deeper headers inherit original parent color and source typography`, value.headers.filter(header => header.depth >= 2).map(({ id, style }) => ({ id, style })));
|
||
check(value.hub.paths === 3 && near(value.hub.iconBox?.width, 20) && near(value.hub.iconBox?.height, 20) && near(value.hub.gap, 8) && value.hub.style.fontSize === '21px' && value.hub.style.fontWeight === '750' && value.hub.style.lineHeight === '24px', `${label}: original hub icon and source text metrics`, value.hub);
|
||
check(value.hub.iconBox && value.hub.labelBox && [value.hub.iconBox, value.hub.labelBox].every(box => box.x >= value.hub.box.x - 0.5 && box.y >= value.hub.box.y - 0.5 && box.x + box.width <= value.hub.box.x + value.hub.box.width + 0.5 && box.y + box.height <= value.hub.box.y + value.hub.box.height + 0.5), `${label}: hub icon and label remain inside source pill`, value.hub);
|
||
check(value.status.every(group => group.heading === (group.status === 'dispatched' ? '파견' : '휴직')) && value.cards.filter(card => card.statusGroup).every(card => card.statusLabels === 0), `${label}: compact status headers retained and below-name labels removed`, value.status);
|
||
check(value.table?.title === '직위별 인원 현황 (팀별)' && JSON.stringify(value.table?.header) === JSON.stringify(['팀원', ...positions, '소계']) && JSON.stringify(value.table?.rows) === JSON.stringify(expectedRows) && JSON.stringify(value.table?.total) === JSON.stringify(expectedTotal) && fixture.employees.length === 41, `${label}: position matrix cross-counts original 41 employees`, { actual: value.table, expectedRows, expectedTotal });
|
||
check(value.bar && value.bar.titleCount === 0 && near(value.bar.box.height, 55.4) && value.bar.items.every(item => near(item.box.y, value.bar.items[0].box.y)) && value.bar.items.every(item => item.logoLoaded) && value.bar.items.every(item => item.count === (item.id === 'total' ? fixture.employees.length : fixture.employees.filter(employee => employee.affiliationId === item.id).length)), `${label}: untitled one-row affiliation bar height55.4 and original counts/logos`, value.bar);
|
||
check(value.cards.every(card => card.issues.length === 0), `${label}: card text has no horizontal overflow or element collisions`, value.cards.filter(card => card.issues.length).map(({ id, issues, textBoxes }) => ({ id, issues, textBoxes })));
|
||
check(value.collisions.length === 0 && value.moduleCollisions.length === 0 && value.outsideStage.length === 0, `${label}: cards/headers/modules do not overlap or fall outside stage`, { collisions: value.collisions, modules: value.moduleCollisions, outside: value.outsideStage });
|
||
check(value.document.scrollWidth <= value.document.clientWidth && value.document.scrollHeight <= value.document.clientHeight && value.screen.stage.right <= viewport.width + 1 && value.screen.stage.bottom <= viewport.height + 1 && value.screen.composition.width <= viewport.width && value.screen.composition.height >= value.screen.stage.height - 1, `${label}: proportionally fitted whole composition; no document scrolling or viewport clipping`, { scale: value.scale, baseWidth: value.baseWidth, baseHeight: value.baseHeight, document: value.document, screen: value.screen });
|
||
check(value.debugCount === 0 && value.cards.every(card => card.keyboardRole === 'button' && card.tabIndex === 0), `${label}: view-only cards retain keyboard information lookup without edit/collapse UI`, { debugCount: value.debugCount });
|
||
}
|
||
|
||
async function dialogs(page, label, viewport) {
|
||
await page.setViewportSize(viewport); await ready(page);
|
||
const targets = await page.evaluate(() => {
|
||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||
const selectors = [
|
||
['representative', '.hm-orgchart-top-leaders .hm-orgchart-employee'],
|
||
['depth1', '.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'],
|
||
['depth2', '.node[data-organization-depth="2"] .hm-orgchart-employee[data-card-kind="leader"]'],
|
||
['employee', '.hm-orgchart-engine .hm-orgchart-employee[data-card-kind="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'],
|
||
];
|
||
return selectors.map(([kind, selector]) => ({ kind, selector, id: shadow.querySelector(selector)?.dataset.employeeId }));
|
||
});
|
||
for (const target of targets) {
|
||
if (!target.id) { check(false, `${label}: ${target.kind} dialog target exists`); continue; }
|
||
const trigger = page.locator(target.selector).first(), dialog = page.locator('dialog.hm-orgchart-employee-dialog[open]');
|
||
await trigger.click(); await dialog.waitFor({ state: 'visible' });
|
||
const value = await dialog.evaluate(element => {
|
||
const rect = element.getBoundingClientRect(), shadow = element.getRootNode();
|
||
const fields = [...element.querySelectorAll('dt')].map(term => [term.textContent, term.nextElementSibling.textContent]);
|
||
return { employeeId: element.dataset.employeeId, fields, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom }, insideStage: !!element.closest('.hm-orgchart-stage'), transform: getComputedStyle(element).transform, ariaModal: element.getAttribute('aria-modal'), focus: shadow.activeElement?.className, editCount: element.querySelectorAll('input,select,textarea,[contenteditable]').length };
|
||
});
|
||
interactions.push({ route: label, viewport, target: target.kind, ...value });
|
||
const employee = fixture.employees.find(employee => employee.id === target.id);
|
||
const fields = Object.fromEntries(value.fields);
|
||
check(value.employeeId === employee.id && fields['이름'] === '홍 길 동' && fields['직위'] === employee.position && fields['소속'] === organizationById.get(employee.organizationId)?.name && (!employee.jobTitle || fields['직책'] === employee.jobTitle) && value.fields.every(([field]) => ['이름', '직위', '소속', '직책', '관계사', '근무 상태'].includes(field)) && value.editCount === 0, `${label}/${viewport.width}: ${target.kind} dialog shows only real fixture fields`, value);
|
||
check(!value.insideStage && value.transform === 'none' && value.ariaModal === 'true' && value.rect.width >= Math.min(300, viewport.width - 32) && value.rect.right <= viewport.width + 1 && value.rect.bottom <= viewport.height + 1 && value.rect.x >= 0 && value.rect.y >= 0, `${label}/${viewport.width}: ${target.kind} modal remains unscaled and unclipped`, value.rect);
|
||
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
|
||
const trapped = await dialog.evaluate(element => element.contains(element.getRootNode().activeElement));
|
||
check(trapped && value.focus === 'hm-orgchart-employee-dialog-close', `${label}/${viewport.width}: ${target.kind} modal receives and traps keyboard focus`);
|
||
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' });
|
||
check(await trigger.evaluate(element => element.getRootNode().activeElement === element), `${label}/${viewport.width}: ${target.kind} ESC restores triggering card focus`);
|
||
await trigger.press('Enter'); await dialog.waitFor({ state: 'visible' });
|
||
await dialog.locator('.hm-orgchart-employee-dialog-close').click(); await dialog.waitFor({ state: 'hidden' });
|
||
check(await trigger.evaluate(element => element.getRootNode().activeElement === element), `${label}/${viewport.width}: ${target.kind} Enter opens; Close restores card focus`);
|
||
}
|
||
}
|
||
|
||
async function configure(page, transport, options) {
|
||
if (transport === 'react') {
|
||
await page.evaluate(({ links, callback }) => {
|
||
if (typeof window.OrgChartP1React?.setOptions !== 'function') throw new Error('React P1 example does not expose public-prop setOptions seam');
|
||
window.OrgChartP1React.setOptions({ organizationLinks: links, onEmployeeClick: callback ? (employee, context) => {
|
||
window.__p3CallbackEvents.push({ employeeId: employee.id, organizationId: context.organization?.id, affiliationId: context.affiliation?.id, triggerId: context.trigger.dataset.employeeId, eventType: context.originalEvent.type });
|
||
} : undefined });
|
||
}, options);
|
||
} else {
|
||
await page.evaluate(async ({ links, callback }) => {
|
||
const seam = window.OrgChartP1; seam.chart.destroy();
|
||
seam.chart = seam.api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new seam.api.JsonOrgChartDataSource(seam.data), stylesheetUrls: seam.stylesheetUrls, displayMode: 'readOnly', organizationLinks: links,
|
||
onEmployeeClick: callback ? (employee, context) => {
|
||
window.__p3CallbackEvents.push({ employeeId: employee.id, organizationId: context.organization?.id, affiliationId: context.affiliation?.id, triggerId: context.trigger.dataset.employeeId, eventType: context.originalEvent.type });
|
||
} : undefined });
|
||
await seam.chart.mount();
|
||
}, options);
|
||
}
|
||
await ready(page);
|
||
}
|
||
async function linksAndCallbacks(page, transport, routeUrl, targetUrl) {
|
||
const major = fixture.organizations.find(organization => depthOf(organization.id) === 1);
|
||
await page.evaluate(() => { window.__p3CallbackEvents = []; });
|
||
await configure(page, transport, { links: { [major.id]: targetUrl }, callback: true });
|
||
const info = await page.evaluate(() => {
|
||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||
return { links: [...shadow.querySelectorAll('.hm-orgchart-organization-link')].map(link => ({ href: link.href, role: link.tagName, tabIndex: link.tabIndex, id: link.closest('.node').dataset.organizationId })), ordinaryTitles: [...shadow.querySelectorAll('.node[data-organization-depth="1"] .hm-orgchart-organization-name')].filter(header => !header.querySelector('a')).length };
|
||
});
|
||
check(info.links.length === 1 && info.links[0].id === major.id && info.links[0].href === targetUrl && info.links[0].role === 'A' && info.links[0].tabIndex === 0 && info.ordinaryTitles === 5, `${transport}: only explicitly mapped 1Depth title is a native link`, info);
|
||
const card = page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee');
|
||
await card.click();
|
||
await page.waitForFunction(() => window.__p3CallbackEvents.length === 1);
|
||
const first = await page.evaluate(() => ({ callbacks: window.__p3CallbackEvents, openDialogs: document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('dialog[open]').length }));
|
||
check(first.callbacks.length === 1 && first.openDialogs === 0 && first.callbacks[0].triggerId === first.callbacks[0].employeeId && first.callbacks[0].organizationId && first.callbacks[0].affiliationId, `${transport}: custom callback fires once with fixture/context and replaces default dialog`, first);
|
||
await card.press('Enter'); await card.press('Space');
|
||
const keyboard = await page.evaluate(() => window.__p3CallbackEvents);
|
||
check(keyboard.length === 3 && keyboard.slice(1).every(event => event.eventType === 'keydown'), `${transport}: Enter/Space each activate callback once`, keyboard);
|
||
await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.blur());
|
||
let reached = false;
|
||
for (let step = 0; step < 60 && !reached; step++) {
|
||
await page.keyboard.press('Tab');
|
||
reached = await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.classList.contains('hm-orgchart-organization-link') ?? false);
|
||
}
|
||
const linkFocus = await page.locator('.hm-orgchart-organization-link').evaluate(element => ({ matches: element.matches(':focus-visible'), outlineStyle: getComputedStyle(element).outlineStyle, outlineWidth: getComputedStyle(element).outlineWidth }));
|
||
check(reached && linkFocus.matches && linkFocus.outlineStyle === 'solid' && parseFloat(linkFocus.outlineWidth) >= 2, `${transport}: real Tab reaches title link with visible focus`, linkFocus);
|
||
check((await page.evaluate(() => window.__p3CallbackEvents)).length === 3, `${transport}: title focus does not activate employee callback`);
|
||
await Promise.all([page.waitForURL(targetUrl), page.keyboard.press('Enter')]);
|
||
check(await page.locator('#proof').textContent() === 'Configured organization link reached', `${transport}: native keyboard link reaches configured verifier URL`);
|
||
await page.goto(routeUrl); await ready(page);
|
||
await page.evaluate(() => { window.__p3CallbackEvents = []; });
|
||
await configure(page, transport, { links: {}, callback: false });
|
||
check(await page.locator('.hm-orgchart-organization-link').count() === 0, `${transport}: removing configured links restores ordinary titles`);
|
||
await page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee').click();
|
||
await page.locator('dialog[open]').waitFor({ state: 'visible' });
|
||
check(true, `${transport}: clearing custom callback restores default Core dialog`);
|
||
await page.keyboard.press('Escape');
|
||
}
|
||
function sameGeometry(a, b) {
|
||
if (!a || !b || a.cards.length !== b.cards.length || a.headers.length !== b.headers.length) return false;
|
||
const boxesEqual = (first, second) => ['x', 'y', 'width', 'height'].every(key => near(first[key], second[key], 0.4));
|
||
return a.cards.every((card, index) => ['id', 'kind', 'size', 'asset', 'name', 'role', 'badge'].every(key => card[key] === b.cards[index][key]) && boxesEqual(card.box, b.cards[index].box))
|
||
&& a.headers.every((header, index) => header.id === b.headers[index].id && boxesEqual(header.box, b.headers[index].box))
|
||
&& JSON.stringify(a.connectors) === JSON.stringify(b.connectors)
|
||
&& JSON.stringify(a.table && { ...a.table, box: undefined }) === JSON.stringify(b.table && { ...b.table, box: undefined })
|
||
&& a.table && b.table && boxesEqual(a.table.box, b.table.box);
|
||
}
|
||
|
||
let browser;
|
||
try {
|
||
const [htmlOrigin, reactOrigin] = await Promise.all([serve(path.join(root, 'examples/org-chart/dist')), serve(path.join(root, 'examples/org-chart-react/dist'))]);
|
||
// The user identified the live 6041 Storybook; never silently verify a stale
|
||
// storybook-static build from P2 instead of that requested running server.
|
||
const storybookOrigin = process.env.ORGCHART_STORYBOOK_URL?.replace(/\/$/, '') ?? 'http://127.0.0.1:6041';
|
||
browser = await launchChromium({ headless: true });
|
||
const context = await browser.newContext({ deviceScaleFactor: 1, reducedMotion: 'reduce' });
|
||
const routes = [
|
||
{ label: 'html', transport: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly` },
|
||
{ label: 'file-classic', transport: 'html', url: `${pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href}?mode=readOnly` },
|
||
{ label: 'react', transport: 'react', url: `${reactOrigin}/p1.html?mode=readOnly` },
|
||
{ label: 'storybook', transport: null, url: `${storybookOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` },
|
||
];
|
||
for (const route of routes) {
|
||
const page = await context.newPage(); captureErrors(page, route.label);
|
||
try {
|
||
await page.setViewportSize(viewports[0]); await page.goto(route.url); await ready(page);
|
||
for (const viewport of viewports) {
|
||
await page.setViewportSize(viewport); await ready(page);
|
||
const value = await measure(page); measurements.push({ route: route.label, ...viewport, ...value });
|
||
verify(value, `${route.label}/${viewport.width}×${viewport.height}`, viewport);
|
||
if (viewport.width === 1920 || viewport.width === 390) {
|
||
const filename = `${route.label}-${viewport.width}x${viewport.height}.png`;
|
||
await page.screenshot({ path: path.join(output, filename), fullPage: true }); screenshots.push(filename);
|
||
}
|
||
}
|
||
await dialogs(page, route.label, viewports[0]);
|
||
await dialogs(page, route.label, viewports.at(-1));
|
||
if (route.label === 'html' || route.label === 'react') {
|
||
await page.setViewportSize(viewports[0]);
|
||
await linksAndCallbacks(page, route.transport, route.url, `${route.label === 'html' ? htmlOrigin : reactOrigin}/__p3_link_proof`);
|
||
}
|
||
} catch (error) { check(false, `${route.label}: P3 browser verification completed`, { message: error.message, stack: error.stack }); }
|
||
finally { await page.close(); }
|
||
}
|
||
for (const viewport of viewports) {
|
||
const values = ['html', 'file-classic', 'react', 'storybook'].map(route => measurements.find(item => item.route === route && item.width === viewport.width));
|
||
check(values.every(Boolean) && values.slice(1).every(value => sameGeometry(values[0], value)), `HTML/classic/React/Story shared Core geometry and matrix/${viewport.width}`, { routes: values.map(value => value?.route ?? 'missing'), basePixelTolerance: 0.4 });
|
||
}
|
||
const fitPage = await context.newPage(); captureErrors(fitPage, 'host-fit');
|
||
await fitPage.setViewportSize({ width: 1920, height: 650 }); await fitPage.goto(routes[0].url); await ready(fitPage);
|
||
const shortValue = await measure(fitPage); measurements.push({ route: 'short-height', width: 1920, height: 650, ...shortValue });
|
||
check(shortValue.document.scrollHeight <= shortValue.document.clientHeight && shortValue.screen.stage.bottom <= 650 && shortValue.scale <= (650 - shortValue.screen.composition.y) / shortValue.baseHeight + 0.002, 'short1920×650: fit respects actual visible height without assuming host header', shortValue.screen);
|
||
await fitPage.setViewportSize(viewports[0]);
|
||
await fitPage.evaluate(() => { document.getElementById('chart-host').style.height = '400px'; }); await ready(fitPage);
|
||
const fixed = await measure(fitPage); measurements.push({ route: 'fixed400-host', width: 1920, height: 1080, ...fixed });
|
||
check(fixed.screen.stage.height <= 400.5 && fixed.screen.composition.height <= 400.5 && fixed.scale <= 400 / fixed.baseHeight + 0.002 && fixed.screen.stage.bottom <= fixed.screen.host.y + 400.5, 'explicit400px container: fit uses host height without clipping whole composition', { scale: fixed.scale, screen: fixed.screen });
|
||
await fitPage.evaluate(() => {
|
||
const host = document.getElementById('chart-host'); host.style.height = ''; host.style.maxHeight = '400px';
|
||
}); await ready(fitPage);
|
||
const maxHeight = await measure(fitPage); measurements.push({ route: 'max-height400-host', width: 1920, height: 1080, ...maxHeight });
|
||
check(maxHeight.screen.stage.height <= 400.5 && maxHeight.screen.composition.height <= 400.5 && maxHeight.scale <= 400 / maxHeight.baseHeight + 0.002, 'max-height400px container: fit respects ancestor max-height', { scale: maxHeight.scale, screen: maxHeight.screen });
|
||
await fitPage.evaluate(() => {
|
||
const host = document.getElementById('chart-host'); host.style.maxHeight = ''; host.style.height = '400px'; host.style.boxSizing = 'border-box'; host.style.padding = '20px';
|
||
const chartRoot = host.querySelector('.hm-orgchart-host'); const inner = document.createElement('div'); inner.style.padding = '12px'; inner.style.boxSizing = 'border-box';
|
||
host.append(inner); inner.append(chartRoot);
|
||
}); await ready(fitPage);
|
||
const padded = await measure(fitPage); measurements.push({ route: 'nested-padding400-host', width: 1920, height: 1080, ...padded });
|
||
check(padded.screen.stage.height <= 336.5 && padded.scale <= 336 / padded.baseHeight + 0.002 && padded.screen.stage.bottom <= padded.screen.host.y + 336.5, '400px border-box ancestor: fit subtracts20px outer and12px inner vertical padding', { scale: padded.scale, screen: padded.screen });
|
||
await fitPage.close();
|
||
|
||
const index = await (await fetch(`${storybookOrigin}/index.json`)).json();
|
||
const entries = Object.values(index.entries ?? {});
|
||
const visible = entries.filter(entry => entry.title === 'OrgChart' && entry.type === 'story' && !entry.tags?.includes('internal'));
|
||
check(JSON.stringify(visible.map(entry => entry.name).sort()) === JSON.stringify(['Logo · Personnel Summary', 'Department Colors', 'Full Composition'].sort()), 'Storybook index: exactly three user-facing OrgChart pages', visible.map(({ id, name, tags }) => ({ id, name, tags })));
|
||
check(['orgchart--logo', 'orgchart--position-summary', 'orgchart--affiliation-summary'].every(id => entries.some(entry => entry.id === id && entry.tags?.includes('internal'))), 'Storybook index: independent component verification Stories retained');
|
||
const combined = await context.newPage(); captureErrors(combined, 'combined-summary');
|
||
await combined.goto(`${storybookOrigin}/iframe.html?id=orgchart--logo-personnel-summary&viewMode=story`);
|
||
await combined.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-component-host')].filter(host => host.shadowRoot?.querySelector('[data-component]')).length >= 3);
|
||
const combinedContent = await combined.evaluate(() => [...document.querySelectorAll('.hm-orgchart-component-host')].map(host => [...host.shadowRoot.querySelectorAll('[data-component]')].map(element => element.dataset.component)).flat());
|
||
check(['Logo', 'PositionSummary', 'AffiliationSummary'].every(name => combinedContent.includes(name)), 'Storybook combined page renders all three existing Core components', combinedContent);
|
||
await combined.close();
|
||
const manager = await context.newPage(); captureErrors(manager, 'storybook-manager');
|
||
await manager.goto(`${storybookOrigin}/?path=/story/orgchart--full-composition`);
|
||
await manager.locator('a[href*="orgchart--full-composition"]').first().waitFor({ state: 'visible', timeout: 20000 });
|
||
const sidebar = await manager.evaluate(() => [...document.querySelectorAll('a[href*="/story/orgchart--"]')].filter(element => element.getBoundingClientRect().width && element.getBoundingClientRect().height).map(element => ({ name: element.textContent.trim(), href: element.getAttribute('href') })));
|
||
check(sidebar.length === 3 && ['logo-personnel-summary', 'department-colors', 'full-composition'].every(id => sidebar.some(item => item.href.includes(`orgchart--${id}`))), 'Storybook manager: actual visible OrgChart sidebar contains only three pages', sidebar);
|
||
await manager.close();
|
||
|
||
const interactivePage = await context.newPage(); captureErrors(interactivePage, 'interactive');
|
||
await interactivePage.goto(`${htmlOrigin}/p1.html`); await ready(interactivePage, 'interactive');
|
||
const preserved = await interactivePage.evaluate(() => {
|
||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||
return { toggles: shadow.querySelectorAll('.hm-orgchart-toggle').length, visibleRoutes: [...shadow.querySelectorAll('.hm-orgchart-engine .hierarchy')].some(element => !['none', 'normal'].includes(getComputedStyle(element, '::before').content) && getComputedStyle(element, '::before').display !== 'none'), statusLabels: shadow.querySelector('.hm-orgchart-status-group .hm-orgchart-status-label')?.textContent };
|
||
});
|
||
check(preserved.toggles > 0 && preserved.visibleRoutes && ['파견', '휴직'].includes(preserved.statusLabels), 'interactive view preserves existing collapse, hierarchy routes and status labels', preserved);
|
||
await interactivePage.close();
|
||
check(createHash('sha256').update(await readFile(fixturePath)).digest('hex') === fixtureHash, 'P3 verifier preserves original 41-person fixture bytes', { sha256: fixtureHash });
|
||
check(errors.length === 0, 'P3 routes have no browser runtime/console errors', errors);
|
||
} catch (error) { check(false, 'P3 verifier setup/completion', { message: error.message, stack: error.stack }); }
|
||
finally {
|
||
await writeFile(path.join(output, 'browser.json'), JSON.stringify({
|
||
browser: browser?.version(), scope: 'P3 impacts only; P2/old suites and expectations are unchanged', fixtureSha256: fixtureHash,
|
||
transportPolicy: 'HTTP ESM/React/Storybook and true file:// classic without file-access Chrome flags',
|
||
testLinkPolicy: '/__p3_link_proof URLs exist only in the verifier-owned local HTTP servers; no actual product URL/data was invented',
|
||
connectorRoutingInk: 'NOT VERIFIED automatically: inspect representative screenshots for curved route/ink intersections',
|
||
textInkCollision: 'NOT VERIFIED automatically: confirmed gap0 may overlap font envelopes; measured element boxes and horizontal text limits are checked',
|
||
originalPhotoPolicy: 'Workdesk has rectangular photo slots and no explicit missing-photo asset. Fictitious cards use transparent empty slots, no real employee photo copied.',
|
||
results, errors, measurements, interactions, screenshots,
|
||
}, null, 2));
|
||
await browser?.close();
|
||
for (const server of servers) await new Promise(resolve => server.close(resolve));
|
||
}
|
||
const failures = results.filter(result => result.status === 'FAIL');
|
||
console.log(JSON.stringify({ output, checks: results.length, failureCount: failures.length, failures: failures.slice(0, 30).map(result => result.name), ...(failures.length > 30 ? { additionalFailures: failures.length - 30 } : {}) }, null, 2));
|
||
if (failures.length) process.exitCode = 1;
|