// P9 focused visual integration checks. Historical P7/P8 full suites stay unrun. import fs from 'node:fs/promises'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-p9'); const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6061').replace(/\/$/, ''); const args = process.argv.slice(2), baselineMode = args.includes('--baseline'); const sections = args.flatMap((arg, index) => arg === '--section' ? (args[index + 1] ?? '').split(',') : arg.startsWith('--section=') ? arg.slice(10).split(',') : []).filter(Boolean); const run = name => !sections.length || sections.includes(name); const widths = args.filter(arg => arg.startsWith('--width=')).map(arg => Number(arg.slice(8))); const suffix = (baselineMode ? '-baseline' : sections.length ? '-' + sections.join('-') : '') + (widths.length ? '-' + widths.join('-') : ''); const startedAt = new Date().toISOString(), results = [], measurements = [], errors = []; await fs.mkdir(output, { recursive: true }); const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, origin, baselineMode, sections, results, measurements, errors }, null, 2)); const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist(); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; const stories = { overview: 'orgchart--logo-personnel-summary', department: 'orgchart--department-colors', fixed: 'orgchart--full-composition', expanded: 'orgchart--expanded-org-chart', }; const sizes = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 390, height: 844 }]; const selectedSizes = sizes.filter(size => !widths.length || widths.includes(size.width)); const colors = { hanmac: '#cb2e28', saman: '#f08300', 'jangheon-industry': '#4580a6', jangheon: '#1d418d', ptc: '#84338e', halla: '#0068b7', baron: '#0c367f' }; const browser = await launchChromium({ headless: true }); const context = await browser.newContext({ viewport: sizes[1] }); context.on('page', target => { target.on('pageerror', error => { errors.push({ type: 'pageerror', url: target.url(), message: error.message }); persist(); }); target.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', url: target.url(), message: message.text(), location: message.location() }); persist(); } }); }); const page = await context.newPage(); page.setDefaultTimeout(20000); let expectedHosts = 1; async function settle() { await page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); } async function ready(requirePage = true) { if (requirePage) await page.locator('.sb-page-title').waitFor(); await page.waitForFunction(expected => { const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')]; return hosts.length === expected && hosts.every(host => host.shadowRoot && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) && !host.shadowRoot.querySelector('[aria-busy="true"]') && [...host.shadowRoot.querySelectorAll('img')].every(image => image.complete)); }, expectedHosts); await settle(); } async function goto(name, size = sizes[1]) { expectedHosts = name === 'overview' ? 3 : 1; await page.setViewportSize(size); await page.goto(`${origin}/iframe.html?id=${stories[name]}&viewMode=story`); await ready(); } async function capture(name, fullPage = true) { await page.screenshot({ path: path.join(output, name + '.png'), fullPage }); } async function select(label, option) { await page.getByRole('combobox', { name: label, exact: true }).click(); await page.getByRole('option', { name: option, exact: true }).click(); await ready(); } async function section(name, work) { if (!run(name)) return; try { await work(); } catch (error) { check(false, `${name}: runner completed`, { message: error.message, stack: error.stack }); } } const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b); async function snapshot() { return page.evaluate(async () => { const shadows = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')].map(host => host.shadowRoot).filter(Boolean); const all = selector => shadows.flatMap(shadow => [...shadow.querySelectorAll(selector)]); const first = selector => all(selector)[0]; const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }; const properties = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'backgroundImage', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderRadius', 'boxShadow', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'textAlign', 'whiteSpace', 'overflowWrap', 'verticalAlign']; const style = (element, dimensions = false) => element ? Object.fromEntries([...properties, ...(dimensions ? ['width', 'height'] : [])].map(key => [key, getComputedStyle(element)[key]])) : null; const table = first('.hm-orgchart-workdesk-position-table'); const summary = table ? { title: first('.hm-orgchart-organization-totals-title')?.textContent, rows: [...table.rows].map(row => [...row.cells].map(cell => ({ text: cell.textContent, tag: cell.tagName, scope: cell.getAttribute('scope'), position: cell.dataset.position ?? null, total: cell.dataset.totalCount ?? null }))), styles: { section: style(first('.hm-orgchart-organization-totals')), title: style(first('.hm-orgchart-organization-totals-title'), true), frame: style(first('.hm-orgchart-workdesk-summary-frame'), true), table: style(table, true), cells: [...table.rows].map(row => [...row.cells].map(cell => style(cell, true))), }, footer: [...table.tFoot.rows[0].cells].slice(1).map(cell => Number(cell.textContent)), body: [...table.tBodies[0].rows].map(row => [...row.cells].slice(1).map(cell => Number(cell.textContent))), } : null; const bar = first('.hm-orgchart-affiliation-bar'); const affiliation = bar ? { items: [...bar.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ id: item.dataset.affiliationId ?? 'total', count: item.dataset.employeeCount ?? item.dataset.totalCount, name: item.querySelector('dt')?.textContent, value: item.querySelector('dd')?.textContent })), styles: { bar: style(bar), items: [...bar.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ item: style(item), name: style(item.querySelector('dt')), value: style(item.querySelector('dd')), mark: style(item.querySelector('.hm-orgchart-affiliation-mark'), true), imageFit: item.querySelector('img') ? getComputedStyle(item.querySelector('img')).objectFit : null })) }, box: rect(bar), } : null; const wrapper = first('.hm-orgchart-composition'), stage = first('.hm-orgchart-stage'); const scale = Number(wrapper?.dataset.scale ?? 1), stageBox = rect(stage); const round = value => Math.round(value * 20) / 20; const fixed = stage ? { scale, wrapper: rect(wrapper), stage: stageBox, baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, matrix: getComputedStyle(stage).transform, stageStyle: style(stage), background: getComputedStyle(wrapper).backgroundColor, htmlHash: [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(stage.innerHTML)))].map(byte => byte.toString(16).padStart(2, '0')).join(''), preflightElements: all('.hm-orgchart-stage .hm-orgchart-employee,.hm-orgchart-stage .hm-orgchart-organization-totals,.hm-orgchart-stage .hm-orgchart-organization-totals-title,.hm-orgchart-stage .hm-orgchart-workdesk-summary-frame,.hm-orgchart-stage .hm-orgchart-workdesk-position-table,.hm-orgchart-stage .hm-orgchart-workdesk-position-table th,.hm-orgchart-stage .hm-orgchart-workdesk-position-table td,.hm-orgchart-stage .hm-orgchart-affiliation-bar,.hm-orgchart-stage .hm-orgchart-affiliation-bar-list,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item dt,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item dd,.hm-orgchart-stage .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark').map(element => ({ className: element.className, text: element.textContent, style: Object.fromEntries(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'backgroundImage', 'width', 'height', 'padding', 'border', 'borderRadius', 'boxShadow', 'textAlign', 'whiteSpace', 'gap', 'display', 'flexWrap', 'alignItems', 'justifyContent'].map(key => [key, getComputedStyle(element)[key]])) })), invariants: all('.hm-orgchart-stage .hm-orgchart-employee,.hm-orgchart-stage .hm-orgchart-organization,.hm-orgchart-stage .hm-orgchart-organization-totals,.hm-orgchart-stage .hm-orgchart-affiliation-bar,.hm-orgchart-stage .hm-orgchart-workdesk-overlay').map(element => { const r = rect(element); return { className: element.className, id: element.dataset.employeeId ?? element.dataset.organizationId ?? null, box: { x: round((r.x - stageBox.x) / scale), y: round((r.y - stageBox.y) / scale), width: round(r.width / scale), height: round(r.height / scale) }, style: style(element, true) }; }), scroll: ['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => { const element = first(selector); if (!element) return { selector, missing: true }; const css = getComputedStyle(element); return { selector, left: element.scrollLeft, top: element.scrollTop, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, overflowX: css.overflowX, overflowY: css.overflowY }; }), } : null; const logos = all('[data-component="Logo"] .hm-orgchart-affiliation-mark').map(mark => { const img = mark.querySelector('img'); return { id: mark.dataset.affiliationId, available: mark.dataset.logoAvailable, box: rect(mark), loaded: Boolean(img?.complete && img.naturalWidth > 0), naturalWidth: img?.naturalWidth, naturalHeight: img?.naturalHeight, fit: img ? getComputedStyle(img).objectFit : null, style: style(mark) }; }); const department = first('[data-component="DepartmentColors"]'); const departments = department ? [...department.querySelectorAll('.hm-orgchart-color-preview')].map(row => ({ name: row.querySelector('.hm-orgchart-color-department-name').textContent, box: rect(row), preview: rect(row.querySelector('.hm-orgchart-preview-grid')), swatches: [...row.querySelectorAll('[data-color-variable]')].map(swatch => ({ variable: swatch.dataset.colorVariable, property: swatch.dataset.cssProperty, value: swatch.parentElement.querySelector('.hm-orgchart-color-value').textContent })), cards: [...row.querySelectorAll('.hm-orgchart-employee')].map(card => ({ kind: card.dataset.cardKind, width: card.offsetWidth, height: card.offsetHeight, box: rect(card), font: getComputedStyle(card).fontFamily, asset: card.dataset.cardAssetSize })), })) : null; const viewport = first('.hm-orgchart-expanded .hm-orgchart-viewport'), forest = first('.hm-orgchart-forest'); const expanded = first('.hm-orgchart-expanded'); const hex = color => color.replace(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g, (_, r, g, b) => '#' + [r, g, b].map(x => Number(x).toString(16).padStart(2, '0')).join('')); const auto = expanded ? { type: expanded.dataset.autoLayout, viewport: rect(viewport), forest: rect(forest), employees: expanded.querySelectorAll('.hm-orgchart-employee').length, titles: [...expanded.querySelectorAll('.node[data-company-id]')].map(node => { const title = node.querySelector('.hm-orgchart-organization-name'), toggle = node.querySelector('.hm-orgchart-expanded-toggle'); return { company: node.dataset.companyId, color: hex(getComputedStyle(title).color), buttonColor: hex(getComputedStyle(toggle).color), background: hex(getComputedStyle(title).backgroundColor) }; }), cards: [...expanded.querySelectorAll('.hm-orgchart-employee')].map(card => { const css = getComputedStyle(card), direction = card.dataset.connectionDirection; return { company: card.dataset.companyId, direction, width: card.offsetWidth, height: card.offsetHeight, border: hex(css[direction === 'left' ? 'borderLeftColor' : direction === 'right' ? 'borderRightColor' : 'borderTopColor']), image: Boolean(card.querySelector('img')) }; }), connectors: expanded.querySelectorAll('svg,.edge,.lines').length, } : null; const common = Object.fromEntries(['.sb-page-title', '.sb-page-description', '.sb-page-section-title', '.sb-page-section-header'].map(selector => [selector, style(document.querySelector(selector))])); return { title: document.querySelector('.sb-page-title')?.textContent, titles: [...document.querySelectorAll('.sb-page-section-title')].map(element => element.textContent), document: { width: innerWidth, scrollWidth: document.documentElement.scrollWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight }, common, summary, affiliation, logos, departments, fixed, auto }; }); } function noHorizontalOverflow(state) { return state.document.scrollWidth <= state.document.width + 1; } function contains(outer, inner, tolerance = 1) { return outer && inner && inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.right <= outer.right + tolerance && inner.bottom <= outer.bottom + tolerance; } function fitted(state) { return state.auto && contains(state.auto.viewport, state.auto.forest, 2); } function affiliationStyles(styles) { // Overview's permitted wrapping uses vertical content padding rather than a // fixed 55.4px bar height. Every item/typography/logo style remains shared. return { ...styles, bar: Object.fromEntries(Object.entries(styles.bar).filter(([key]) => !['paddingTop', 'paddingBottom'].includes(key))) }; } function sourceInvariant(fixed) { return { width: fixed.baseWidth, height: fixed.baseHeight, style: fixed.stageStyle, contents: fixed.invariants }; } function geometryNear(a, b) { if (!a || !b || a.width !== b.width || a.height !== b.height || !equal(a.style, b.style) || a.contents.length !== b.contents.length) return false; return a.contents.every((item, index) => { const other = b.contents[index]; return item.className === other.className && item.id === other.id && equal(item.style, other.style) && Object.keys(item.box).every(key => Math.abs(item.box[key] - other.box[key]) <= 0.1); }); } try { if (baselineMode) { const snapshots = {}; for (const name of ['overview', 'department', 'fixed']) { await goto(name); snapshots[name] = await snapshot(); await capture(`baseline-${name}-1440`); } check(Boolean(snapshots.fixed.summary && snapshots.fixed.affiliation && snapshots.fixed.fixed), 'Baseline: Fixed shared presenters and geometry captured'); await fs.writeFile(path.join(output, 'visual-baseline.json'), JSON.stringify({ startedAt, completedAt: new Date().toISOString(), origin, viewport: sizes[1], snapshots }, null, 2)); } else { const baselinePath = await fs.access(path.join(output, 'preflight.json')).then(() => 'preflight.json', () => 'visual-baseline.json'); const baseline = JSON.parse(await fs.readFile(path.join(output, baselinePath), 'utf8')); const preflightFixed = baselinePath === 'preflight.json' ? baseline.snapshots.fixed[0] : null; const preflightHash = preflightFixed ? createHash('sha256').update(preflightFixed.stage.html).digest('hex') : null; await goto('fixed'); const reference = await snapshot(); await section('overview', async () => { for (const size of selectedSizes) { await goto('overview', size); const state = await snapshot(); measurements.push({ name: `Overview ${size.width}`, state }); check(equal(state.titles, ['관계사·회사 로고', '조직·직위별 인원 현황', '관계사별 인원 현황']), `Overview ${size.width}: exact three confirmed section titles`, state.titles); check(Boolean(state.summary) && equal(state.summary.rows, reference.summary.rows), `Overview ${size.width}: table rows, positions and totals equal Fixed`, state.summary?.rows); check(Boolean(state.summary) && equal(state.summary.styles, reference.summary.styles), `Overview ${size.width}: table, heading, cells, borders and font reuse Fixed computed styles`, { overview: state.summary?.styles, fixed: reference.summary.styles }); check(Boolean(state.summary) && state.summary.footer.every(Number.isFinite) && state.summary.footer.slice(0, -1).reduce((a, b) => a + b, 0) === state.summary.footer.at(-1) && state.summary.body.every(row => row.slice(0, -1).reduce((a, b) => a + b, 0) === row.at(-1)), `Overview ${size.width}: numeric position sums and row subtotals reconcile`, { footer: state.summary?.footer, body: state.summary?.body }); check(Boolean(state.affiliation) && equal(state.affiliation.items, reference.affiliation.items) && equal(affiliationStyles(state.affiliation.styles), affiliationStyles(reference.affiliation.styles)), `Overview ${size.width}: affiliation data, typography, logos, border and background reuse Fixed`, { overview: state.affiliation, fixed: reference.affiliation }); const previewBox = await page.locator('.sb-page-section').last().locator('.sb-page-section-body').boundingBox(); check(state.affiliation?.box.x >= previewBox.x - 1 && state.affiliation.box.right <= previewBox.x + previewBox.width + 1 && state.affiliation.box.y >= previewBox.y - 1 && state.affiliation.box.bottom <= previewBox.y + previewBox.height + 1, `Overview ${size.width}: complete affiliation bar is contained in page flow`, { bar: state.affiliation?.box, preview: previewBox }); check(state.logos.length === 7 && state.logos.every(logo => logo.available === 'true' && logo.loaded && logo.fit === 'contain' && logo.naturalWidth > 0 && logo.naturalHeight > 0), `Overview ${size.width}: seven approved logos loaded with preserved contain sizing`, state.logos); check(noHorizontalOverflow(state), `Overview ${size.width}: no document horizontal overflow`, state.document); check(equal(state.common, reference.common), `Overview ${size.width}: common StoryPage typography and border remain identical`); await capture(`overview-${size.width}`); } }); await section('department', async () => { for (const size of selectedSizes) { await goto('department', size); const state = await snapshot(); measurements.push({ name: `Department ${size.width}`, state }); check(state.departments?.length === 6 && state.departments.every(row => row.swatches.every(swatch => /#[0-9a-f]{6}/i.test(swatch.value))), `Department ${size.width}: six preserved organization color previews have computed HEX values`, state.departments?.map(row => ({ name: row.name, values: row.swatches }))); check(state.departments?.every(row => row.cards.every(card => card.width === (card.kind === 'employee' ? 108 : 146) && card.height === (card.kind === 'employee' ? 52 : 68) && card.font.includes('Noto Sans KR') && contains(row.box, card.box))), `Department ${size.width}: original card geometry, font and containment`, state.departments?.map(row => ({ name: row.name, cards: row.cards }))); check(noHorizontalOverflow(state) && equal(state.common, reference.common), `Department ${size.width}: responsive page retains shared frame styles`, state.document); check(state.departments?.every((row, index, rows) => rows.every((other, otherIndex) => otherIndex === index || Math.abs(row.box.y - other.box.y) > 1 || Math.abs(row.preview.y - other.preview.y) <= 1)), `Department ${size.width}: card-preview starts align across each shared row`, state.departments?.map(row => ({ name: row.name, rowY: row.box.y, previewY: row.preview.y }))); measurements.push({ name: `Department ${size.width} card-preview alignment`, previews: state.departments?.map(row => ({ name: row.name, row: row.box, preview: row.preview })) }); await capture(`department-colors-${size.width}`); } }); await section('fixed', async () => { for (const size of selectedSizes) { await goto('fixed', size); const before = await snapshot(); await page.locator('.hm-orgchart-composition').scrollIntoViewIfNeeded(); const box = await page.locator('.hm-orgchart-composition').boundingBox(); await page.mouse.move(box.x + box.width / 2, box.y + Math.min(30, box.height / 2)); await page.mouse.wheel(80, 80); await settle(); const state = await snapshot(), fixed = state.fixed; measurements.push({ name: `Fixed ${size.width}`, state }); const matrix = fixed.matrix.match(/matrix\(([^)]+)\)/)?.[1].split(',').map(Number); check(fixed.scale > 0 && fixed.scale <= 1 && matrix && Math.abs(matrix[0] - matrix[3]) < 0.0001 && contains(fixed.wrapper, fixed.stage) && fixed.invariants.every(item => item.box.x >= -1 && item.box.y >= -1 && item.box.x + item.box.width <= fixed.baseWidth + 1 && item.box.y + item.box.height <= fixed.baseHeight + 1), `Fixed ${size.width}: complete chart bounds and proportional fit`, { scale: fixed.scale, matrix, wrapper: fixed.wrapper, stage: fixed.stage }); check(fixed.scroll.every(item => item.missing || (item.left === 0 && item.top === 0 && (item.overflowX === 'hidden' || item.scrollWidth <= item.clientWidth + 1) && (item.overflowY === 'hidden' || item.scrollHeight <= item.clientHeight + 1))), `Fixed ${size.width}: no actual internal scroll after wheel`, { before: before.fixed.scroll, after: fixed.scroll }); const preserved = preflightFixed ? fixed.baseWidth === preflightFixed.stage.width && fixed.baseHeight === preflightFixed.stage.height && equal(fixed.preflightElements, preflightFixed.elements) && fixed.htmlHash === preflightHash : geometryNear(sourceInvariant(fixed), sourceInvariant(baseline.snapshots.fixed.fixed)) && equal(state.summary, baseline.snapshots.fixed.summary) && equal(state.affiliation.items, baseline.snapshots.fixed.affiliation.items) && equal(state.affiliation.styles, baseline.snapshots.fixed.affiliation.styles); check(preserved, `Fixed ${size.width}: baseline card/layout and summary styling preserved`, { baseline: baselinePath, naturalWidth: fixed.baseWidth, naturalHeight: fixed.baseHeight, baselineElementCount: preflightFixed?.elements.length, elementCount: fixed.preflightElements.length, computedStylesEqual: preflightFixed ? equal(fixed.preflightElements, preflightFixed.elements) : undefined, stageDOMHashEqual: preflightFixed ? fixed.htmlHash === preflightHash : undefined }); check(noHorizontalOverflow(state), `Fixed ${size.width}: no document horizontal overflow`, state.document); await page.evaluate(() => scrollTo(0, 0)); await capture(`full-composition-${size.width}`); } }); await section('expanded', async () => { for (const size of selectedSizes) { await goto('expanded', size); for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { if (type !== 'tb') await select('조직도 표시 유형', option); const state = await snapshot(); measurements.push({ name: `Expanded ${type} ${size.width}`, state }); check(state.auto?.type === type && state.auto.employees === 20 && fitted(state) && noHorizontalOverflow(state), `Expanded ${type} ${size.width}: representative layout renders unchanged sample and Fits`, state.auto); check(state.auto?.titles.length > 0 && state.auto.titles.every(title => title.color === '#ffffff' && title.buttonColor === '#ffffff' && title.background === colors[title.company]) && new Set(state.auto.titles.map(title => title.company)).size === 7, `Expanded ${type} ${size.width}: all company title backgrounds and white text preserved`, state.auto?.titles); check(state.auto?.cards.every(card => card.border === colors[card.company] && ['top', 'left', 'right'].includes(card.direction)) && state.auto.connectors > 0, `Expanded ${type} ${size.width}: company colored directional borders and connectors`, state.auto?.cards); await capture(`expanded-${type}-${size.width}`); } } if (!widths.length) { await goto('expanded'); const toggle = page.locator('.hm-orgchart-expanded-toggle:not(:disabled)').first(), id = await toggle.getAttribute('data-organization-id'); await toggle.hover(); check(await toggle.evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: hover title remains white'); await toggle.focus(); check(await toggle.evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: focus title remains white'); await toggle.press('Enter'); await ready(); const current = () => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); check(await current().getAttribute('aria-expanded') === 'false' && await current().evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: collapsed title remains white'); await current().press('Space'); await ready(); check(await current().getAttribute('aria-expanded') === 'true' && await current().evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: expanded title remains white'); } }); await section('dialog', async () => { for (const size of [sizes[1], sizes[2]]) { await goto('expanded', size); const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id'); await employee.click(); const dialog = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await dialog.waitFor({ state: 'visible' }); await settle(); const state = await dialog.evaluate(element => { const r = element.getBoundingClientRect(); return { role: element.getAttribute('role'), employeeId: element.dataset.employeeId, portal: element.parentElement === document.body, focusInside: element.contains(document.activeElement), x: r.x, right: r.right, width: r.width, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }; }); check(state.role === 'dialog' && state.employeeId === employeeId && state.portal && state.focusInside && await page.getByRole('dialog').count() === 1 && await page.locator('dialog[open]').count() === 0 && state.x >= -1 && state.right <= size.width + 1 && state.scrollWidth <= state.clientWidth + 1, `HM Dialog ${size.width}: actual single HM Portal, identity, focus and responsive bounds`, state); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); check(await dialog.evaluate(element => element.contains(document.activeElement)), `HM Dialog ${size.width}: Tab focus remains inside`); await capture(`hm-dialog-${size.width}`, false); const focusedEmployee = () => page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle(); check(await focusedEmployee() === employeeId, `HM Dialog ${size.width}: ESC returns focus to source card`); await employee.press('Enter'); await dialog.waitFor({ state: 'visible' }); await dialog.getByRole('button', { name: '닫기', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }); await settle(); check(await focusedEmployee() === employeeId, `HM Dialog ${size.width}: close button returns focus to source card`); } }); await section('menu', async () => { const response = await page.request.get(`${origin}/index.json`), index = await response.json(); const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal')); check(entries.length === 4 && equal(entries.map(entry => entry.id).sort(), Object.values(stories).sort()), 'Storybook: exactly four public OrgChart pages and all retained IDs', entries.map(entry => ({ id: entry.id, title: entry.title }))); }); await section('independent', async () => { expectedHosts = 1; await page.setViewportSize(sizes[1]); for (const [id, kind] of [['orgchart--position-summary', 'PositionSummary'], ['orgchart--affiliation-summary', 'AffiliationSummary']]) { await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`); await ready(false); const state = await page.evaluate(kind => { const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot, root = shadow.querySelector(`[data-component="${kind}"]`); return { kind, rootClass: root.className, total: root.querySelector('.hm-orgchart-raw-total')?.textContent, header: [...root.querySelectorAll('thead th')].map(element => element.textContent), footer: root.querySelector('tfoot')?.textContent, affiliationItems: root.querySelectorAll('.hm-orgchart-affiliation-item').length, workdeskPresenters: shadow.querySelectorAll('.hm-orgchart-workdesk-position-table,.hm-orgchart-affiliation-bar,.hm-orgchart-summary-preview').length }; }, kind); check(state.workdeskPresenters === 0 && state.total?.includes('41') && (kind === 'PositionSummary' ? state.rootClass.includes('hm-orgchart-position-summary') && state.header.includes('소계 (직접)') && state.header.includes('하위 포함') && state.footer.includes('전체 합계') : state.rootClass === 'hm-orgchart-affiliation-summary' && state.affiliationItems === 7), `Independent ${kind}: default presenter and aggregation contract preserved`, state); } }); } } catch (error) { check(false, 'P9 runner completed', { message: error.message, stack: error.stack }); } finally { check(errors.length === 0, 'No new browser console or page errors', errors); const result = { startedAt, completedAt: new Date().toISOString(), origin, baselineMode, sections, widths, browser: browser.version(), results, measurements, errors, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length, limitations: ['Focused P9 verification only; historical P7/P8 full suites and 2,100 employee tests were not repeated.', 'Shared computed styles demonstrate runtime reuse; CSS design intent approval and original Guard NOT VERIFIED remain separate.', 'StoryPage document vertical scroll is distinct from chart internal scrolling.', 'White text company colors remain approved; Saman and Jangheon Industry contrast remains a recorded accessibility issue.', 'Other browsers, physical devices, screen readers, SSR and deployment remain NOT VERIFIED.'] }; await fs.writeFile(path.join(output, `browser${suffix}.json`), JSON.stringify(result, null, 2)); console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9/browser${suffix}.json` })); await context.close(); await browser.close(); if (result.fail) process.exitCode = 1; }