Files
HMDesign/examples/org-chart/verify-p2.mjs

339 lines
33 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 { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
// P2-only browser evidence. Existing v0/P1 expectations and reports are retained.
// ESM HTML and React use HTTP; the classic HTML is opened directly with file://.
// No Chrome file-access flag is used, so classic portability is tested honestly.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p2');
await mkdir(output, { recursive: true });
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 expectedArrows = [[194.15, 311.75, 66.25, 19.8], [430.4, 311.75, 66.25, 19.8], [666.65, 302, 183.75, 42], [1044, 302, 258.92, 42], [1520.55, 305, 135.35, 36]];
const results = [], measurements = [], errors = [], screenshots = [], servers = [];
const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const near = (actual, expected, tolerance = 0.2) => Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance;
const spacedKorean = text => text.replace(/([가-힣])(?=[가-힣])/g, '$1 ');
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' };
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)) 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(async () => {
const host = document.querySelector('.hm-orgchart-host');
const shadow = host.shadowRoot;
const composition = shadow.querySelector('.hm-orgchart-composition');
const stage = shadow.querySelector('.hm-orgchart-stage');
const origin = stage.getBoundingClientRect();
// dataset.scale is rounded to four digits; measured transform is required
// to recover the small cards accurately at the 390px viewport.
const scale = origin.width / stage.offsetWidth;
const rect = element => {
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 dimensions = element => element ? { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY } : null;
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 fingerprint = source => {
let hash = 2166136261;
for (let index = 0; index < source.length; index++) hash = Math.imul(hash ^ source.charCodeAt(index), 16777619);
return (hash >>> 0).toString(16);
};
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee[data-card-asset]')].map(card => {
const node = card.closest('.node[data-organization-id]');
const background = card.querySelector('.hm-orgchart-card-background');
const css = getComputedStyle(card);
const personName = card.querySelector('.hm-orgchart-person-name');
const cardBox = rect(card), avatarBox = rect(card.querySelector('.hm-orgchart-avatar'));
const logoBox = card.querySelector('.hm-orgchart-affiliation-mark') ? rect(card.querySelector('.hm-orgchart-affiliation-mark')) : null;
const contentIssues = [], textBoxes = [], fontRangeOverlaps = [];
const personText = card.querySelector('.hm-orgchart-person-text');
const positionLine = personText?.firstElementChild;
const lineBoxes = { position: positionLine ? rect(positionLine) : null, name: rect(personName) };
const role = card.querySelector('.hm-orgchart-person-role');
const badge = card.querySelector('.hm-orgchart-position-row .hm-orgchart-job-title');
const horizontalRoleBadgeGap = badge ? rect(badge).x - rect(role).x - rect(role).width : null;
if (lineBoxes.position && lineBoxes.position.y + lineBoxes.position.height > lineBoxes.name.y + 0.3) contentIssues.push('position/name: actual line-box overlap');
if (horizontalRoleBadgeGap !== null && horizontalRoleBadgeGap < -0.3) contentIssues.push('role/badge: horizontal element-box overlap');
const contained = box => box.x >= cardBox.x - 0.5 && box.y >= cardBox.y - 0.5 && box.x + box.width <= cardBox.x + cardBox.width + 0.5 && box.y + box.height <= cardBox.y + cardBox.height + 0.5;
for (const element of card.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title')) {
if (element.clientWidth > 0 && element.scrollWidth > element.clientWidth + 1) contentIssues.push(`${element.className}: horizontal text overflow`);
const range = document.createRange(); range.selectNodeContents(element);
for (const box of range.getClientRects()) {
const textBox = { x: (box.left - origin.left) / scale, y: (box.top - origin.top) / scale, width: box.width / scale, height: box.height / scale };
if (!contained(textBox)) contentIssues.push(`${element.className}: text outside card`);
if (overlaps(textBox, avatarBox)) contentIssues.push(`${element.className}: avatar overlap`);
if (logoBox && overlaps(textBox, logoBox)) contentIssues.push(`${element.className}: logo overlap`);
textBoxes.push({ className: element.className, box: textBox });
}
}
// Range rectangles contain the font ascent/descent envelope, including
// unpainted space. Noto Sans KR can make adjacent 0-gap line envelopes
// intersect despite distinct element line boxes and non-overlapping ink.
// Retain these metrics for visual review; never classify them as glyph
// collisions or silently claim that screenshot pixels were verified.
for (const [index, a] of textBoxes.entries()) for (const b of textBoxes.slice(index + 1)) if (a.className !== b.className && overlaps(a.box, b.box)) fontRangeOverlaps.push({ a: a.className, b: b.className, horizontal: Math.min(a.box.x + a.box.width, b.box.x + b.box.width) - Math.max(a.box.x, b.box.x), vertical: Math.min(a.box.y + a.box.height, b.box.y + b.box.height) - Math.max(a.box.y, b.box.y) });
if (!contained(avatarBox)) contentIssues.push('avatar outside card');
if (logoBox && !contained(logoBox)) contentIssues.push('logo outside card');
return {
employeeId: card.dataset.employeeId, organizationId: node?.dataset.organizationId ?? null,
depth: node ? Number(node.dataset.organizationDepth ?? node.dataset.depth) : null,
kind: card.dataset.cardKind, asset: card.dataset.cardAsset, size: card.dataset.cardAssetSize,
topSlot: !!card.closest('.hm-orgchart-top-leaders'), box: rect(card),
naturalWidth: background?.naturalWidth ?? 0, naturalHeight: background?.naturalHeight ?? 0,
embedded: !!background?.src.startsWith('data:image/svg+xml'), loaded: !!background?.complete && background.naturalWidth > 0,
name: text(personName), role: text(card.querySelector('.hm-orgchart-person-role')), badge: text(card.querySelector('.hm-orgchart-job-title')),
nameAccessible: personName.getAttribute('aria-label'), roleAccessible: card.querySelector('.hm-orgchart-person-role')?.getAttribute('aria-label'), badgeAccessible: card.querySelector('.hm-orgchart-job-title')?.getAttribute('aria-label'),
nameStyle: { fontSize: getComputedStyle(personName).fontSize, fontWeight: getComputedStyle(personName).fontWeight, textOverflow: getComputedStyle(personName).textOverflow },
contentIssues, textBoxes, lineBoxes, horizontalRoleBadgeGap, fontRangeOverlaps,
radius: css.borderRadius, border: css.borderWidth, fill: css.backgroundColor,
};
});
const headers = [...shadow.querySelectorAll('.node[data-organization-id] .hm-orgchart-organization-name')].map(header => {
const node = header.closest('.node');
const card = node.querySelector('.hm-orgchart-employee');
const box = rect(header);
const wrapperCss = getComputedStyle(header.parentElement);
return { organizationId: node.dataset.organizationId, depth: Number(node.dataset.organizationDepth ?? node.dataset.depth), text: text(header), box,
firstCardKind: card?.dataset.cardKind ?? null, headerCardGap: card ? rect(card).y - box.y - box.height : null,
wrapperBorder: wrapperCss.borderWidth, wrapperFill: wrapperCss.backgroundColor, wrapperShadow: wrapperCss.boxShadow };
});
const overlay = shadow.querySelector('.hm-orgchart-workdesk-overlay');
const arrows = await Promise.all([...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay image[data-arrow]')].map(async element => {
const source = element.getAttribute('href') ?? element.getAttributeNS('http://www.w3.org/1999/xlink', 'href') ?? '';
const image = new Image(); image.src = source;
await image.decode().catch(() => {});
return { id: element.dataset.arrow, x: Number(element.getAttribute('x')), y: Number(element.getAttribute('y')), width: Number(element.getAttribute('width')), height: Number(element.getAttribute('height')),
box: rect(element), embedded: source.startsWith('data:image/png'), fingerprint: fingerprint(source), loaded: image.complete && image.naturalWidth > 0, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight,
preserveAspectRatio: element.getAttribute('preserveAspectRatio') };
}));
const decorations = [...shadow.querySelectorAll('img[data-workdesk-asset]')].map(image => ({ asset: image.dataset.workdeskAsset, box: rect(image), embedded: image.src.startsWith('data:image/'), fingerprint: fingerprint(image.src), loaded: image.complete && image.naturalWidth > 0, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight }));
const connectors = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay path[data-connector]')].map(element => ({ id: element.dataset.connector, path: element.getAttribute('d'), stroke: getComputedStyle(element).stroke, strokeWidth: getComputedStyle(element).strokeWidth, dasharray: getComputedStyle(element).strokeDasharray, fill: getComputedStyle(element).fill, box: rect(element) }));
const physical = [...cards.map(card => ({ type: 'card', id: card.employeeId, box: card.box })), ...headers.map(header => ({ type: 'header', id: header.organizationId, 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.type}:${a.id}`, b: `${b.type}:${b.id}` });
const arrowCollisions = arrows.flatMap(arrow => physical.filter(item => overlaps(arrow.box, item.box)).map(item => ({ arrow: arrow.id, item: `${item.type}:${item.id}` })));
const summaries = [...shadow.querySelectorAll('.hm-orgchart-organization-totals, .hm-orgchart-affiliation-bar')].map(element => ({ className: element.className, box: rect(element), text: text(element) }));
const summaryCollisions = summaries.flatMap(summary => physical.filter(item => overlaps(summary.box, item.box)).map(item => ({ summary: summary.className, item: `${item.type}:${item.id}` })));
const pseudo = selector => [...shadow.querySelectorAll(selector)].map(element => {
const before = getComputedStyle(element, '::before'), after = getComputedStyle(element, '::after');
return { className: element.className, before: { content: before.content, display: before.display }, after: { content: after.content, display: after.display } };
});
return {
scale, recordedScale: composition.dataset.scale, baseWidth: origin.width / scale, baseHeight: origin.height / scale,
stageTransform: getComputedStyle(stage).transform, displayMode: composition.dataset.displayMode,
document: dimensions(document.documentElement), viewport: dimensions(shadow.querySelector('.hm-orgchart-viewport')),
cards, headers, arrows, decorations, connectors, overlayViewBox: overlay?.getAttribute('viewBox') ?? null,
presentationNames: [...shadow.querySelectorAll('.hm-orgchart-employee .hm-orgchart-person-name')].map(element => ({ text: text(element), accessible: element.getAttribute('aria-label'), overflow: element.scrollWidth > element.clientWidth })),
presentationPeople: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ employeeId: card.dataset.employeeId,
role: text(card.querySelector('.hm-orgchart-person-role')), roleAccessible: card.querySelector('.hm-orgchart-person-role')?.getAttribute('aria-label'),
badge: text(card.querySelector('.hm-orgchart-job-title')), badgeAccessible: card.querySelector('.hm-orgchart-job-title')?.getAttribute('aria-label') })),
collisions, arrowCollisions, summaryCollisions, summaries,
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,
hostControls: document.querySelectorAll('.hm-orgchart-story-controls').length,
largeMatrixCount: shadow.querySelectorAll('.hm-orgchart-position-table, .hm-orgchart-position-summary').length,
totalsCount: shadow.querySelectorAll('.hm-orgchart-organization-totals').length,
affiliationBarCount: shadow.querySelectorAll('.hm-orgchart-affiliation-bar').length,
affiliationBarRows: [...shadow.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(element => rect(element).y),
affiliationTotal: text(shadow.querySelector('.hm-orgchart-affiliation-bar-total dd')),
statusGroups: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(element => ({ status: element.dataset.workStatus, text: text(element.querySelector('h3')), box: rect(element), border: getComputedStyle(element).borderWidth })),
originalBranchPseudos: pseudo('.hm-orgchart-axis, .hm-orgchart-hub, .hm-orgchart-forest, .hm-orgchart-tree'),
};
});
}
function verify(value, label, viewport) {
const top = value.cards.filter(card => card.kind === 'topLeader');
const depth1 = value.cards.filter(card => card.kind === 'leader' && card.depth === 1);
const deeper = value.cards.filter(card => card.kind === 'leader' && card.depth >= 2);
const sizeMatches = (card, size, width, height) => card.size === size && near(card.box.width, width) && near(card.box.height, height) && card.naturalWidth === width && card.naturalHeight === height;
check(top.length === 1 && top[0].topSlot && sizeMatches(top[0], 'representative', 146, 74), `${label}: representative alone uses 146×74 card`);
check(depth1.length === 6 && depth1.every(card => sizeMatches(card, 'leader', 146, 68)), `${label}: all six actual Depth 1 leaders use 146×68 cards`, depth1.map(card => ({ id: card.employeeId, size: card.size, box: card.box })));
check(deeper.length > 0 && deeper.every(card => sizeMatches(card, 'employee', 108, 52)), `${label}: every actual Depth ≥2 leader uses 108×52 card`, deeper.map(card => ({ id: card.employeeId, depth: card.depth, size: card.size, box: card.box })));
check(deeper.every(card => spacedKorean(fixture.employees.find(employee => employee.id === card.employeeId)?.jobTitle ?? '') === card.badge && card.badge), `${label}: small leader cards preserve supplied job-title badges with requested Korean spacing`, deeper.map(card => ({ id: card.employeeId, badge: card.badge })));
check(value.presentationNames.length > 0 && value.presentationNames.every(name => name.text === '홍 길 동' && name.accessible === '홍길동' && !name.overflow), `${label}: every displayed person name is 홍 길 동, fits, and retains an unspaced accessible name`, value.presentationNames);
check(value.presentationPeople.every(card => {
const employee = fixture.employees.find(employee => employee.id === card.employeeId);
return employee && card.role === spacedKorean(employee.position) && card.roleAccessible === employee.position && card.badge === spacedKorean(employee.jobTitle ?? '') && (!employee.jobTitle || card.badgeAccessible === employee.jobTitle);
}), `${label}: positions and job-title badges use one normal inter-Korean space while accessible labels retain original text`);
check(value.cards.every(card => card.contentIssues.length === 0), `${label}: text stays within card/avatar/logo boundaries; actual line boxes and horizontal role/badge boxes do not overlap`, value.cards.filter(card => card.contentIssues.length).map(card => ({ id: card.employeeId, issues: card.contentIssues, textBoxes: card.textBoxes, lineBoxes: card.lineBoxes, horizontalRoleBadgeGap: card.horizontalRoleBadgeGap })));
check(value.cards.every(card => card.loaded && card.embedded), `${label}: original embedded card SVG assets load`);
const leaders = value.headers.filter(header => header.firstCardKind === 'leader');
check(leaders.filter(header => header.depth === 1).length === 6 && leaders.every(header => near(header.headerCardGap, header.depth === 1 ? 8 : 10, 0.3)), `${label}: header-to-leader boundingRect gap is 8px at Depth 1 and 10px below`, leaders.map(header => ({ id: header.organizationId, depth: header.depth, gap: header.headerCardGap })));
check(value.displayMode === 'readOnly' && value.debugCount === 0 && value.hostControls === 0, `${label}: read-only composition has no collapse/test/debug content`);
check(value.headers.every(header => header.wrapperBorder === '0px' && header.wrapperFill === 'rgba(0, 0, 0, 0)' && header.wrapperShadow === 'none'), `${label}: organization groups retain unframed presentation`);
check(value.totalsCount === 1 && value.affiliationBarCount === 1 && value.largeMatrixCount === 0, `${label}: compact organization totals and untitled affiliation bar are retained`);
check(value.affiliationTotal === `${new Set(fixture.employees.map(employee => employee.id)).size}명` && Math.max(...value.affiliationBarRows) - Math.min(...value.affiliationBarRows) < 1, `${label}: affiliation counts retain one base-canvas row and fixture total`);
check(value.statusGroups.length === 2 && value.statusGroups.every(group => group.border === '0px' && !group.text.includes('·')), `${label}: dispatched/on-leave groups retain compact presentation`);
check(near(value.baseWidth, 1920, 0.3) && near(value.baseHeight, 914.5, 1) && value.scale > 0 && value.scale <= 1, `${label}: entire 1920×914.5 base stage scales uniformly`, { width: value.baseWidth, height: value.baseHeight, scale: value.scale });
check(value.document.scrollWidth <= viewport.width + 1, `${label}: document has no horizontal overflow`, value.document);
check(value.overlayViewBox?.replace(/\s+/g, ' ').trim() === '0 0 1920 914.5', `${label}: connectors and arrows share stage coordinates`);
check(value.arrows.length === 5 && value.arrows.every((arrow, index) => [arrow.x, arrow.y, arrow.width, arrow.height].every((number, coordinate) => near(number, expectedArrows[index][coordinate], 0.05)) && arrow.preserveAspectRatio === 'none' && arrow.embedded && arrow.loaded), `${label}: five embedded collaboration arrows retain source placement`, value.arrows);
check(value.decorations.length === 5 && value.decorations.every(asset => asset.embedded && asset.loaded), `${label}: five Workdesk decorations load without external URLs`, value.decorations);
check(value.connectors.length === 5 && value.connectors.some(connector => /[Cc]/.test(connector.path ?? '')) && value.connectors.every(connector => connector.path && connector.fill === 'none' && connector.stroke === 'rgb(69, 99, 159)' && near(parseFloat(connector.strokeWidth), 0.893, 0.03) && connector.dasharray.split(/[ ,]+/).every(number => near(parseFloat(number), 1.79, 0.03))), `${label}: source overlay curves retain stroke, fill, width and dash pattern`, value.connectors);
check(value.collisions.length === 0, `${label}: card/header rectangles do not overlap`, value.collisions);
check(value.arrowCollisions.length === 0, `${label}: collaboration arrow rectangles do not intersect cards/headers`, value.arrowCollisions);
check(value.summaryCollisions.length === 0, `${label}: compact summary rectangles do not intersect cards/headers`, value.summaryCollisions);
}
async function tooltip(page, transport) {
const lowerOrganization = fixture.organizations.find(organization => organization.parentId && fixture.organizations.find(parent => parent.id === organization.parentId)?.parentId && organization.leaderId);
const employeeId = lowerOrganization.leaderId;
const fullName = '홍 길 동';
const name = page.locator(`.hm-orgchart-employee[data-employee-id="${employeeId}"] .hm-orgchart-person-name`);
await name.waitFor({ state: 'visible' });
// All requested default names must fit. Induce a narrow name box locally to
// exercise overflow behavior without altering fixture or presentation text.
const originalInlineStyle = await name.getAttribute('style');
await name.evaluate(element => { element.style.width = '8px'; element.style.maxWidth = '8px'; });
await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`)?.dataset.nameOverflow === 'true', employeeId);
const before = await page.evaluate(id => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const name = shadow.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`);
const tip = shadow.getElementById(name.getAttribute('aria-describedby'));
return { fullText: name.textContent, tabIndex: name.tabIndex, ellipsis: getComputedStyle(name).textOverflow, tipHidden: tip.hidden, documentHeight: document.documentElement.scrollHeight, viewportHeight: shadow.querySelector('.hm-orgchart-viewport').scrollHeight };
}, employeeId);
check(before.fullText === fullName && before.tabIndex === 0 && before.ellipsis === 'ellipsis' && before.tipHidden, `${transport}: induced overflow preserves 홍 길 동 and exposes focusable ellipsis`, before);
await name.hover();
const tip = page.locator('.hm-orgchart-person-name-tooltip:not([hidden])');
await tip.waitFor({ state: 'visible' });
check(await tip.textContent() === fullName, `${transport}: hover exposes complete requested name in tooltip`);
await tip.hover();
check(await tip.isVisible(), `${transport}: tooltip remains hoverable`);
await page.mouse.move(0, 0);
await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('[role=tooltip]')].every(tip => tip.hidden));
// The only overflowed name is in the normal tab order: use the keyboard,
// rather than locator.focus(), to prove actual Tab activation.
await page.evaluate(() => document.activeElement?.blur());
let reached = false;
for (let step = 0; step < 100 && !reached; step++) {
await page.keyboard.press('Tab');
reached = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.closest(`[data-employee-id="${id}"]`)?.dataset.employeeId === id, employeeId);
}
check(reached && await tip.isVisible(), `${transport}: keyboard Tab focus opens complete tooltip`);
await page.keyboard.press('Escape');
check(!(await tip.isVisible()), `${transport}: Escape dismisses tooltip while focus stays on name`);
const after = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
return { documentHeight: document.documentElement.scrollHeight, viewportHeight: shadow.querySelector('.hm-orgchart-viewport').scrollHeight, activeClass: shadow.activeElement?.className };
});
check(before.documentHeight === after.documentHeight && before.viewportHeight === after.viewportHeight && after.activeClass === 'hm-orgchart-person-name', `${transport}: hidden tooltip does not inflate scroll extents or lose focus`, { before, after });
await page.keyboard.press('Tab');
check(!(await tip.isVisible()), `${transport}: moving focus keeps tooltip dismissed`);
await name.evaluate((element, originalInlineStyle) => originalInlineStyle === null ? element.removeAttribute('style') : element.setAttribute('style', originalInlineStyle), originalInlineStyle);
await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`)?.dataset.nameOverflow === 'false', employeeId);
check(await name.getAttribute('tabindex') === null && await name.getAttribute('aria-describedby') === null, `${transport}: restoring the name box removes overflow-only focus and tooltip attributes`);
}
function sameGeometry(a, b) {
if (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) => {
const other = b.cards[index];
return ['employeeId', 'organizationId', 'depth', 'kind', 'asset', 'size', 'badge', 'name', 'role'].every(key => card[key] === other[key]) && boxesEqual(card.box, other.box);
}) && a.headers.every((header, index) => header.organizationId === b.headers[index].organizationId && boxesEqual(header.box, b.headers[index].box))
&& JSON.stringify(a.connectors.map(({ id, path }) => ({ id, path }))) === JSON.stringify(b.connectors.map(({ id, path }) => ({ id, path })))
&& JSON.stringify(a.arrows.map(({ fingerprint, x, y, width, height }) => ({ fingerprint, x, y, width, height }))) === JSON.stringify(b.arrows.map(({ fingerprint, x, y, width, height }) => ({ fingerprint, x, y, width, height })));
}
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'))]);
const storybookOrigin = process.env.ORGCHART_STORYBOOK_URL?.replace(/\/$/, '') ?? await serve(path.join(root, 'storybook-static'));
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);
const filename = `${route.label}-${viewport.width}x${viewport.height}.png`;
await page.screenshot({ path: path.join(output, filename), fullPage: true }); screenshots.push(filename);
}
if (route.transport && route.label !== 'file-classic') {
await page.setViewportSize(viewports[1]); await ready(page);
await tooltip(page, route.transport);
}
} catch (error) { check(false, `${route.label}: browser verification completed`, { message: error.message }); }
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/React/classic/Story shared Core geometry and asset parity/${viewport.width}`, { routes: values.map(value => value?.route ?? 'missing'), toleranceInBasePixels: 0.4 });
}
const interactive = await context.newPage(); captureErrors(interactive, 'interactive');
await interactive.goto(`${htmlOrigin}/p1.html`); await ready(interactive, 'interactive');
const preserved = await interactive.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const node = [...shadow.querySelectorAll('.node')].find(node => Number(node.dataset.organizationDepth ?? node.dataset.depth) >= 2 && node.querySelector('[data-card-kind=leader]'));
const card = node?.querySelector('[data-card-kind=leader]');
return { organizationId: node?.dataset.organizationId, size: card?.dataset.cardAssetSize, width: card?.offsetWidth, height: card?.offsetHeight, toggleCount: shadow.querySelectorAll('.hm-orgchart-toggle').length, overlayCount: shadow.querySelectorAll('.hm-orgchart-workdesk-overlay').length };
});
check(preserved.size === 'leader' && preserved.width === 146 && preserved.height === 68 && preserved.toggleCount > 0 && preserved.overlayCount === 0, 'interactive default preserves existing lower-leader large-card policy and controls', preserved);
await interactive.close();
check(createHash('sha256').update(await readFile(fixturePath)).digest('hex') === fixtureHash, 'P2 browser verifier preserves original employee fixture bytes and IDs', { sha256: fixtureHash });
check(errors.length === 0, 'P2 routes have no browser console/runtime errors', errors);
} catch (error) { check(false, 'P2 verifier setup/completion', { message: error.message }); }
finally {
await writeFile(path.join(output, 'browser.json'), JSON.stringify({
browser: browser?.version(), scope: 'P2 only; no existing v0/P1 suite expectation changes',
transportPolicy: 'HTTP for ESM/React; true file:// classic without file-access browser flag',
tooltipMethod: 'Induced 8px name width on one rendered 홍 길 동, restored after real hover/Tab/Escape. Fixture and displayed text are unchanged.',
fixtureSha256: fixtureHash,
connectorCollisionVerification: 'NOT VERIFIED by automation: path bounding boxes do not represent curved segments. Review saved full-page screenshots for routing and overlap.',
textInkCollisionVerification: 'NOT VERIFIED by automation: font Range ascent/descent envelopes may overlap at the confirmed 0px internal row gap. Actual element line boxes, horizontal role/badge boxes, horizontal overflow and card/avatar/logo boundaries are checked; fontRangeOverlaps retain raw envelopes for screenshot pixel review.',
results, errors, measurements, 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, 20).map(result => result.name), ...(failures.length > 20 ? { additionalFailures: failures.length - 20 } : {}) }, null, 2));
if (failures.length) process.exitCode = 1;