339 lines
33 KiB
JavaScript
339 lines
33 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';
|
||
|
||
// 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;
|