import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; import http from 'node:http'; import path from 'node:path'; import { readFile, mkdir, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { fileURLToPath, pathToFileURL } from 'node:url'; // P3 impact checks only. P2's 484 checks and every earlier report are retained. // Runtime layout, accessibility interaction, and data aggregation are measured; // font ink and curved connector routing still require saved-image review. const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-p3'); const fixturePath = path.join(root, 'examples/org-chart/data-p1.json'); const fixtureBytes = await readFile(fixturePath); const fixtureHash = createHash('sha256').update(fixtureBytes).digest('hex'); const fixture = JSON.parse(fixtureBytes.toString('utf8')); await loadPlaywright(); const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]; const results = [], measurements = [], interactions = [], errors = [], screenshots = [], servers = []; const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); const near = (actual, expected, tolerance = 0.25) => Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance; const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }; await mkdir(output, { recursive: true }); async function serve(directory) { const server = http.createServer(async (request, response) => { try { const url = new URL(request.url, 'http://127.0.0.1'); if (url.pathname === '/__p3_link_proof') { response.setHeader('Content-Type', 'text/html'); return response.end('P3 verifier navigation proof

Configured organization link reached

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