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;