// P8 Storybook presentation-only browser checks. P7 units/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 { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-p8'); const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6057').replace(/\/$/, ''); const sections = process.argv.slice(2); const run = section => (!sections.length && !['full-repair', 'route-final'].includes(section)) || sections.includes(section); const results = [], measurements = [], errors = []; const startedAt = new Date().toISOString(); await fs.mkdir(output, { recursive: true }); const persist = () => writeFileSync(path.join(output, 'browser-progress.json'), JSON.stringify({ startedAt, origin, sections, results, measurements, errors }, null, 2)); function 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 = [ { id: 'orgchart--logo-personnel-summary', name: 'Overview', file: 'overview' }, { id: 'orgchart--department-colors', name: 'Department Colors', file: 'department-colors' }, { id: 'orgchart--full-composition', name: 'Full Composition', file: 'full-composition' }, { id: 'orgchart--expanded-org-chart', name: 'Expanded OrgChart', file: 'expanded-org-chart' }, ]; const patterns = ['patterns-data-filter-bar--all', 'patterns-layout-page-header--all', 'patterns-layout-loading-screen--all']; const internalIds = [ 'orgchart--logo', 'orgchart--position-summary', 'orgchart--affiliation-summary', ...['business-management', 'business-planning', 'talent-growth', 'integrated-system', 'design-planning', 'solution-sales', 'top-leader', 'organization-leader', 'employee'].map(name => 'orgchart-components-cards--' + name), ...['position-summary', 'affiliation-summary', 'status-summary'].map(name => 'orgchart-components-summaries--' + name), ...['basic-organization', 'organization-hierarchy', 'employee-cards', 'organization-summary', 'json-data-update', 'loading-empty-error', 'responsive-container', 'multiple-instances'].map(name => 'orgchart-content--' + name), ]; const browser = await launchChromium({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } }); const page = await context.newPage(); context.on('page', p => { p.on('pageerror', error => errors.push({ url: p.url(), type: 'pageerror', message: error.message })); p.on('console', message => { if (message.type() === 'error') errors.push({ url: p.url(), type: 'console', message: message.text(), location: message.location() }); }); }); // The initial page predates the context event listener. page.on('pageerror', error => errors.push({ url: page.url(), type: 'pageerror', message: error.message })); page.on('console', message => { if (message.type() === 'error') errors.push({ url: page.url(), type: 'console', message: message.text(), location: message.location() }); }); page.setDefaultTimeout(20000); async function settle(target = page) { await target.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); } async function ready(expected) { await page.locator('.sb-page-title').waitFor(); await page.waitForFunction(expected => { const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')]; if (!hosts.length) return false; return hosts.every(host => { const shadow = host.shadowRoot; return shadow && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) && !shadow.querySelector('[aria-busy="true"]') && (expected === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === expected); }); }, expected); await settle(); } async function gotoStory(story, width = 1440) { await page.setViewportSize({ width, height: 1100 }); await page.goto(`${origin}/iframe.html?id=${story.id}&viewMode=story`); await ready(story.file === 'expanded-org-chart' ? 20 : undefined); } async function capture(file, fullPage = true) { await page.screenshot({ path: path.join(output, file + '.png'), fullPage }); } 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 }); } } async function commonStyle() { return page.evaluate(() => { const properties = { '.sb-page': ['fontFamily', 'color', 'paddingTop', 'paddingBottom', 'boxSizing', 'width'], '.sb-page-inner': ['width', 'boxSizing'], '.sb-page-header': ['paddingBottom', 'borderBottomWidth', 'borderBottomColor', 'fontSize', 'lineHeight'], '.sb-page-kicker': ['fontSize', 'fontWeight', 'color', 'marginBottom'], '.sb-page-title': ['fontSize', 'fontWeight', 'lineHeight', 'marginTop', 'marginBottom'], '.sb-page-description': ['fontSize', 'fontWeight', 'lineHeight', 'marginBottom'], '.sb-page-section': ['marginTop', 'scrollMarginTop'], '.sb-page-section-header': ['paddingBottom', 'marginBottom', 'borderBottomWidth', 'borderBottomColor'], '.sb-page-section-title': ['fontSize', 'lineHeight', 'fontWeight', 'marginTop', 'marginBottom'], '.sb-page-section-description': ['fontSize', 'lineHeight', 'fontWeight', 'marginTop', 'color'], }; return Object.fromEntries(Object.entries(properties).map(([selector, keys]) => { const element = document.querySelector(selector); if (!element) return [selector, null]; const css = getComputedStyle(element); const style = Object.fromEntries(keys.map(key => [key, css[key]])); if (selector === '.sb-page-inner') { // Chrome can expose auto margins as 0px after the Core's Typed OM // ancestor inspection. Actual symmetric gutters are the user contract. const outer = element.parentElement.getBoundingClientRect(), inner = element.getBoundingClientRect(); style.leftGutter = inner.left - outer.left; style.rightGutter = outer.right - inner.right; } return [selector, style]; })); }); } async function pageGeometry() { return page.evaluate(() => { 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 documentGeometry = { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, innerWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight }; const frame = { page: rect(document.querySelector('.sb-page')), inner: rect(document.querySelector('.sb-page-inner')), title: document.querySelector('.sb-page-title')?.textContent }; const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; const wrapper = shadow?.querySelector('.hm-orgchart-composition'); const stage = shadow?.querySelector('.hm-orgchart-stage'); const fixed = wrapper && stage ? { wrapper: rect(wrapper), stage: rect(stage), baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, scale: Number(wrapper.dataset.scale), matrix: getComputedStyle(stage).transform, scroll: ['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => { const element = shadow.querySelector(selector); if (!element) return { selector, missing: true }; const css = getComputedStyle(element); return { selector, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, scrollLeft: element.scrollLeft, scrollTop: element.scrollTop, overflowX: css.overflowX, overflowY: css.overflowY }; }), visibleBounds: [...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization,.hm-orgchart-summary,.hm-orgchart-logo,.hm-orgchart-workdesk-overlay')].map(element => ({ className: element.className, box: rect(element) })), } : null; return { document: documentGeometry, frame, fixed }; }); } async function autoState() { return page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; const viewport = shadow.querySelector('.hm-orgchart-viewport'), forest = shadow.querySelector('.hm-orgchart-forest'); const rect = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }; return { type: shadow.querySelector('.hm-orgchart-expanded')?.dataset.autoLayout, employees: shadow.querySelectorAll('.hm-orgchart-employee').length, organizations: shadow.querySelectorAll('.node[data-depth]').length, scale: Number(viewport.dataset.scale), x: Number(viewport.dataset.panX), y: Number(viewport.dataset.panY), viewport: rect(viewport), forest: rect(forest), zoomEnabled: viewport.dataset.zoomEnabled, panEnabled: viewport.dataset.panEnabled, scroll: { left: viewport.scrollLeft, top: viewport.scrollTop, width: viewport.scrollWidth, clientWidth: viewport.clientWidth, height: viewport.scrollHeight, clientHeight: viewport.clientHeight } }; }); } const autoFits = state => state.forest.x >= state.viewport.x - 1 && state.forest.y >= state.viewport.y - 1 && state.forest.right <= state.viewport.right + 1 && state.forest.bottom <= state.viewport.bottom + 1; const fixedHasNoInternalScroll = fixed => fixed.scroll.filter(item => !item.missing).every(item => item.scrollLeft === 0 && item.scrollTop === 0 && ( item.selector === '.hm-orgchart-composition' ? item.overflowX === 'hidden' && item.overflowY === 'hidden' : item.scrollWidth <= item.clientWidth + 1 && item.scrollHeight <= item.clientHeight + 1 )); async function select(label, option) { await page.getByRole('combobox', { name: label, exact: true }).click(); await page.getByRole('option', { name: option, exact: true }).click(); await settle(); } try { await section('manager', async () => { const response = await page.request.get(origin + '/index.json'); check(response.ok(), 'Storybook index responds', { status: response.status() }); const index = await response.json(); await fs.writeFile(path.join(output, 'story-index.json'), JSON.stringify(index, null, 2)); const entries = Object.values(index.entries); const publicEntries = entries.filter(entry => (entry.title === 'OrgChart' || entry.title.startsWith('OrgChart/')) && entry.type === 'story' && !entry.tags?.some(tag => ['internal', 'verification'].includes(tag))); check(publicEntries.length === 4 && stories.every(story => publicEntries.some(entry => entry.id === story.id && entry.name === story.name)), 'Exactly four public names and original Story IDs', publicEntries.map(({ id, name, title, tags }) => ({ id, name, title, tags }))); check(internalIds.every(id => index.entries[id]?.tags?.includes('internal')), 'Existing internal Story IDs retained', { expected: internalIds, missingOrPublic: internalIds.filter(id => !index.entries[id]?.tags?.includes('internal')) }); check(new Set(entries.map(entry => entry.id)).size === entries.length, 'Story index contains no duplicate IDs'); const manager = await context.newPage(); await manager.goto(`${origin}/?path=/story/${stories[0].id}`); await manager.locator('#storybook-explorer-tree').waitFor(); await manager.locator(`#storybook-explorer-tree a[id="${stories[0].id}"]`).waitFor(); await manager.waitForFunction(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].some(row => row.dataset.itemId === 'orgchart' && row.dataset.hmDepth === '0')); await manager.waitForFunction(() => { const preview = document.querySelector('#storybook-preview-iframe')?.contentDocument; const hosts = [...preview?.querySelectorAll('.hm-orgchart-component-host') ?? []]; return preview?.querySelector('.sb-page-title')?.textContent === 'Overview' && hosts.length > 0 && hosts.every(host => host.shadowRoot && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)); }); await manager.frameLocator('#storybook-preview-iframe').locator('.sb-page-title').evaluate(async element => { const document = element.ownerDocument, view = document.defaultView; await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => view.requestAnimationFrame(resolve)); }); await settle(manager); const sidebar = await manager.evaluate(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].map(row => { const primary = row.querySelector(':scope > a[id],:scope > button[id]'); const label = primary?.querySelector('.hm-manager-label')?.textContent ?? primary?.textContent ?? row.querySelector(':scope > button')?.textContent ?? row.textContent; return { id: row.dataset.itemId, text: label?.trim(), depth: Number(row.dataset.hmDepth), section: row.dataset.hmSection, type: row.dataset.nodetype, href: primary?.getAttribute('href'), visible: row.getBoundingClientRect().height > 0 }; })); await fs.writeFile(path.join(output, 'manager-sidebar.json'), JSON.stringify(sidebar, null, 2)); const roots = sidebar.filter(row => row.depth === 0 && row.visible); const patternsRoot = roots.findIndex(row => row.id === 'patterns'), orgRoot = roots.findIndex(row => row.id === 'orgchart'), centerRoot = roots.findIndex(row => row.id === 'center-graphic'); check(patternsRoot >= 0 && orgRoot === patternsRoot + 1 && centerRoot === orgRoot + 1, 'OrgChart is between Patterns and Center Graphic at manager depth 0', roots); const publicRows = sidebar.filter(row => stories.some(story => story.id === row.id)); check(JSON.stringify(publicRows.map(row => row.text)) === JSON.stringify(stories.map(story => story.name)) && publicRows.every(row => row.depth === 1), 'Four manager pages follow requested order at one child depth', publicRows); check(sidebar.filter(row => row.id.startsWith('orgchart--')).length === 4 && internalIds.every(id => !sidebar.some(row => row.id === id)), 'Internal pages remain default hidden with no duplicate public row'); const sidebarHeight = await manager.locator('#storybook-sidebar-region').evaluate(element => Math.max(element.scrollHeight, ...[...element.querySelectorAll('[data-radix-scroll-area-content]')].map(item => item.scrollHeight))); await manager.setViewportSize({ width: 1440, height: Math.max(1100, Math.min(2400, sidebarHeight + 100)) }); await settle(manager); await manager.screenshot({ path: path.join(output, 'manager-full-sidebar.png'), fullPage: true }); await manager.locator('#storybook-sidebar-region').screenshot({ path: path.join(output, 'manager-sidebar.png') }); await manager.close(); }); await section('route-final', async () => { const expected = { overview: { hosts: 3, selectors: ['[data-component="Logo"]', '[data-component="PositionSummary"]', '[data-component="AffiliationSummary"]'] }, 'department-colors': { hosts: 1, selectors: ['[data-component="DepartmentColors"]', '.hm-orgchart-employee'] }, 'full-composition': { hosts: 1, selectors: ['.hm-orgchart-composition', '.hm-orgchart-stage', '.hm-orgchart-employee'] }, 'expanded-org-chart': { hosts: 1, selectors: ['.hm-orgchart-expanded', '.hm-orgchart-viewport', '.hm-orgchart-employee'] }, }; for (const story of stories) { await gotoStory(story); const rendered = await page.evaluate(({ selectors }) => { const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')]; return { title: document.querySelector('.sb-page-title')?.textContent, hosts: hosts.length, specimens: Object.fromEntries(selectors.map(selector => [selector, hosts.reduce((total, host) => total + host.shadowRoot.querySelectorAll(selector).length, 0)])), employees: hosts.reduce((total, host) => total + host.shadowRoot.querySelectorAll('.hm-orgchart-employee').length, 0), autoLayout: hosts.find(host => host.classList.contains('hm-orgchart-host'))?.shadowRoot.querySelector('.hm-orgchart-expanded')?.dataset.autoLayout }; }, expected[story.file]); check(rendered.title === story.name && rendered.hosts === expected[story.file].hosts && Object.values(rendered.specimens).every(count => count > 0) && (story.file !== 'expanded-org-chart' || (rendered.employees === 20 && rendered.autoLayout === 'tb')), `${story.name}: final CSF old URL loads expected Core specimens`, { url: page.url(), ...rendered }); measurements.push({ name: story.name + ' final retained route', rendered }); await capture(story.file + '-1440'); } }); await section('styles', async () => { const references = []; for (const id of patterns) { await page.setViewportSize({ width: 1440, height: 1100 }); await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`); await page.locator('.sb-page-title').waitFor(); await settle(); references.push({ id, style: await commonStyle() }); } const reference = references[0].style; check(references.every(item => JSON.stringify(item.style) === JSON.stringify(reference)), 'Three existing Pattern pages share the measured StoryPage frame', references); for (const story of stories) { await gotoStory(story); const style = await commonStyle(); measurements.push({ name: story.name + ' common StoryPage computed styles', style }); check(JSON.stringify(style) === JSON.stringify(reference), story.name + ': StoryPage title, spacing, section and inner styles match Pattern pages', { reference: patterns[0], difference: Object.keys(reference).filter(key => JSON.stringify(reference[key]) !== JSON.stringify(style[key])), style }); await capture(story.file + '-1440'); if (['full-composition', 'expanded-org-chart'].includes(story.file)) { const shielding = await page.evaluate(() => { const pageFrame = document.querySelector('.sb-page'); const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; const keys = ['fontFamily', 'fontSize', 'lineHeight', 'color', 'backgroundColor', 'backgroundImage', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderRadius', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'width', 'height']; const snapshot = () => ['.hm-orgchart-employee', '.hm-orgchart-organization', '.hm-orgchart-organization-name', '.hm-orgchart-person-name', '.hm-orgchart-summary'].flatMap(selector => [...shadow.querySelectorAll(selector)].map(element => ({ selector, id: element.dataset.employeeId ?? element.dataset.organizationId, style: Object.fromEntries(keys.map(key => [key, getComputedStyle(element)[key]])) }))); const withFrame = snapshot(), previous = pageFrame.className; try { pageFrame.className = ''; return { withFrame, withoutFrame: snapshot() }; } finally { pageFrame.className = previous; } }); check(JSON.stringify(shielding.withFrame) === JSON.stringify(shielding.withoutFrame), story.name + ': StoryPage frame does not override Core shadow typography, colors, borders or dimensions', { elements: shielding.withFrame.length, changed: shielding.withFrame.filter((value, index) => JSON.stringify(value) !== JSON.stringify(shielding.withoutFrame[index])) }); await settle(); } } }); await section('widths', async () => { for (const story of stories) for (const width of [390, 768, 1280, 1440]) { await gotoStory(story, width); const geometry = await pageGeometry(); measurements.push({ name: `${story.name} ${width}px`, geometry }); check(geometry.document.scrollWidth <= geometry.document.clientWidth + 1 && geometry.document.scrollWidth <= width + 1, `${story.name} ${width}px: no document horizontal overflow`, geometry.document); check(geometry.frame.title === story.name, `${story.name} ${width}px: direct URL renders requested page`, { url: page.url(), title: geometry.frame.title }); if (geometry.fixed) { const fixed = geometry.fixed; const matrix = fixed.matrix === 'none' ? [1, 0, 0, 1] : fixed.matrix.slice(7, -1).split(',').map(Number); const uniform = Math.abs(matrix[0] - matrix[3]) < 0.0001 && Math.abs(matrix[1]) < 0.0001 && Math.abs(matrix[2]) < 0.0001; const bounds = fixed.stage.x >= fixed.wrapper.x - 1 && fixed.stage.y >= fixed.wrapper.y - 1 && fixed.stage.right <= fixed.wrapper.right + 1 && fixed.stage.bottom <= fixed.wrapper.bottom + 1; const noScroll = fixedHasNoInternalScroll(fixed); check(fixed.scale > 0 && fixed.scale <= 1 && uniform && Math.abs(fixed.stage.width / fixed.baseWidth - matrix[0]) < 0.001 && Math.abs(fixed.stage.height / fixed.baseHeight - matrix[3]) < 0.001 && bounds, `Full Composition ${width}px: uniform scale and entire stage fit content wrapper`, fixed); check(noScroll, `Full Composition ${width}px: no internal horizontal or vertical scroll`, { note: 'The overflow-hidden composition retains unscaled transformed extents in CSSOM; complete visible stage bounds are checked separately.', scroll: fixed.scroll }); check(fixed.visibleBounds.every(({ box }) => box.x >= fixed.stage.x - 1 && box.y >= fixed.stage.y - 1 && box.right <= fixed.stage.right + 1 && box.bottom <= fixed.stage.bottom + 1), `Full Composition ${width}px: cards and summaries remain inside complete stage`, fixed.visibleBounds); } if (width === 390) await capture(story.file + '-390'); } }); await section('full-repair', async () => { await page.setViewportSize({ width: 1440, height: 1100 }); await page.goto(`${origin}/iframe.html?id=${patterns[0]}&viewMode=story`); await page.locator('.sb-page-title').waitFor(); await settle(); const reference = await commonStyle(); await gotoStory(stories[2]); const style = await commonStyle(); const inspection = await page.evaluate(() => ['.sb-page', '.sb-page-inner'].map(selector => { const element = document.querySelector(selector), css = getComputedStyle(element), rect = element.getBoundingClientRect(); return { selector, display: css.display, marginLeft: css.marginLeft, marginRight: css.marginRight, marginInline: css.marginInline, rect: { x: rect.x, y: rect.y, width: rect.width, right: rect.right }, inline: element.getAttribute('style') }; })); check(JSON.stringify(style) === JSON.stringify(reference), 'Full Composition repair: common styles and actual centered gutters match existing Pattern', { style, inspection, reference }); await capture('full-composition-1440'); for (const width of [390, 768, 1280, 1440]) { await gotoStory(stories[2], width); const before = await pageGeometry(); const wrapper = await page.locator('.hm-orgchart-composition').boundingBox(); await page.mouse.move(wrapper.x + wrapper.width / 2, wrapper.y + Math.min(30, wrapper.height / 2)); await page.mouse.wheel(80, 80); await settle(); const after = await pageGeometry(); const fixed = after.fixed; const bounds = fixed.stage.x >= fixed.wrapper.x - 1 && fixed.stage.y >= fixed.wrapper.y - 1 && fixed.stage.right <= fixed.wrapper.right + 1 && fixed.stage.bottom <= fixed.wrapper.bottom + 1; check(fixedHasNoInternalScroll(fixed) && bounds && fixed.visibleBounds.every(({ box }) => box.x >= fixed.stage.x - 1 && box.y >= fixed.stage.y - 1 && box.right <= fixed.stage.right + 1 && box.bottom <= fixed.stage.bottom + 1), `Full Composition repair ${width}px: no wheel-scroll area or clipped transformed contents`, { before: before.fixed.scroll, after: fixed.scroll, wrapper: fixed.wrapper, stage: fixed.stage, document: after.document }); measurements.push({ name: `Full Composition repair ${width}px`, geometry: after }); } }); await section('smoke', async () => { await gotoStory(stories[3]); for (const label of ['데이터 범위', '전체 직원 수', '조직도 표시 유형', '혼합형 시작 Depth', '특정 그룹 세로 배치', '초기 표시 Depth', '직원 정보 팝업']) check(await page.getByRole('combobox', { name: label, exact: true }).count() === 1, `Expanded: ${label} control preserved`); for (const label of ['접기·펼치기 사용', 'Zoom 사용', 'Pan 사용']) check(await page.getByRole('checkbox', { name: label, exact: true }).isChecked(), `Expanded: independent ${label} control enabled`); check(await page.getByRole('checkbox').count() === 10 && await page.getByRole('button', { name: '데이터 적용', exact: true }).count() === 1 && await page.getByRole('button', { name: '초기화', exact: true }).count() === 1, 'Expanded: seven company selections and apply/reset actions preserved'); await select('전체 직원 수', '100명'); check((await autoState()).employees === 20, 'Expanded: draft count waits for data apply'); await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(100); check((await autoState()).employees === 100 && (await page.locator('[data-component="OrgChartExampleCounts"]').textContent()).includes('100'), 'Expanded: data apply renders 100 employee records'); for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { await select('조직도 표시 유형', option); await ready(100); const state = await autoState(); check(state.type === type && state.employees === 100 && autoFits(state), `Expanded: ${type} switch preserves data and Fit`, state); } await select('초기 표시 Depth', '2Depth까지'); await ready(); const depths = await page.locator('.node[data-depth]').evaluateAll(nodes => nodes.map(node => Number(node.dataset.depth))); check(depths.every(depth => depth < 2), 'Expanded: initial Depth control still limits visible groups', depths); await select('초기 표시 Depth', '전체 펼침'); await ready(100); const beforeCollapse = await autoState(); const toggle = page.locator('.hm-orgchart-expanded-toggle').first(); const organizationId = await toggle.getAttribute('data-organization-id'); await toggle.focus(); await toggle.press('Enter'); await ready(); const collapsed = await autoState(); check(await toggle.getAttribute('aria-expanded') === 'false' && collapsed.organizations < beforeCollapse.organizations && collapsed.employees < beforeCollapse.employees, 'Expanded: Enter collapses a group and its descendants', { organizationId, before: beforeCollapse, after: collapsed }); await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${organizationId}"]`).press('Space'); await ready(100); check(await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${organizationId}"]`).getAttribute('aria-expanded') === 'true', 'Expanded: Space expands the same group'); await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).uncheck(); await ready(); check((await autoState()).zoomEnabled === 'false' && await page.getByRole('button', { name: '확대', exact: true }).isDisabled(), 'Expanded: independent Zoom switch disables only Zoom action'); await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).check(); await ready(); await page.getByRole('button', { name: '100% 복원', exact: true }).click(); const beforeZoom = await autoState(); await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); check((await autoState()).scale > beforeZoom.scale, 'Expanded: Zoom changes actual forest scale'); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const beforePan = await autoState(); await page.locator('.hm-orgchart-viewport').scrollIntoViewIfNeeded(); const viewport = await page.locator('.hm-orgchart-viewport').boundingBox(); await page.mouse.move(viewport.x + 10, viewport.y + 10); await page.mouse.down(); await page.mouse.move(viewport.x + 50, viewport.y + 35); await page.mouse.up(); await settle(); const afterPan = await autoState(); check(afterPan.x !== beforePan.x && afterPan.y !== beforePan.y, 'Expanded: pointer Pan changes both forest coordinates', { before: beforePan, after: afterPan }); await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).uncheck(); await ready(); check((await autoState()).panEnabled === 'false' && (await autoState()).zoomEnabled === 'true', 'Expanded: independent Pan switch leaves Zoom enabled'); await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).check(); await ready(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); check(autoFits(await autoState()), 'Expanded: toolbar Fit contains complete forest'); const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id'); await employee.focus(); await employee.press('Enter'); const dialog = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await dialog.waitFor({ state: 'visible' }); await settle(); const popup = await dialog.evaluate(element => ({ role: element.getAttribute('role'), employeeId: element.dataset.employeeId, portal: element.parentElement === document.body, focusInside: element.contains(document.activeElement), text: element.textContent, box: (() => { const r = element.getBoundingClientRect(); return { x: r.x, right: r.right, width: r.width }; })() })); check(popup.role === 'dialog' && popup.employeeId === employeeId && popup.portal && popup.focusInside && await page.getByRole('dialog').count() === 1 && await page.locator('dialog[open]').count() === 0, 'Expanded: actual single HM Dialog with exact employee and Portal focus', popup); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); check(await dialog.evaluate(element => element.contains(document.activeElement)), 'Expanded: HM Dialog traps Tab focus'); await capture('expanded-hm-dialog', false); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle(); const focusedEmployee = () => page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId); check(await focusedEmployee() === employeeId, 'Expanded: ESC closes HM Dialog and returns employee focus'); await employee.press('Space'); await dialog.waitFor({ state: 'visible' }); await dialog.getByRole('button', { name: '닫기', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }); await settle(); check(await focusedEmployee() === employeeId, 'Expanded: HM close button returns employee focus'); await page.setViewportSize({ width: 390, height: 844 }); await settle(); await employee.focus(); await employee.press('Enter'); await dialog.waitFor({ state: 'visible' }); await settle(); const narrow = await dialog.evaluate(element => { const r = element.getBoundingClientRect(); return { x: r.x, right: r.right, width: r.width, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, documentScrollWidth: document.documentElement.scrollWidth, innerWidth }; }); check(narrow.x >= -1 && narrow.right <= 391 && narrow.scrollWidth <= narrow.clientWidth + 1 && narrow.documentScrollWidth <= 390, 'Expanded: actual HM Dialog fits 390px', narrow); await capture('expanded-hm-dialog-390', false); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); // Department Colors remains a live Core specimen with its original option. await gotoStory(stories[1]); const longNameButton = page.getByRole('button', { name: '긴 이름 검증 시작', exact: true }); await longNameButton.click(); await ready(); check(await page.getByRole('button', { name: '긴 이름 검증 종료', exact: true }).getAttribute('aria-pressed') === 'true' && await page.locator('.hm-orgchart-person-name').filter({ hasText: '아주긴가상이름표시검증사례' }).count() > 0, 'Department Colors: original long-name specimen option remains functional'); }); } finally { check(errors.length === 0, 'No new browser page or console errors', errors); const result = { startedAt, completedAt: new Date().toISOString(), origin, sections, browser: browser.version(), results, measurements, errors, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length, limitations: ['Historical P7 units/full suites were not repeated.', 'Common computed styles prove runtime parity; design intent approval remains separate.', 'Document vertical scrolling caused by StoryPage text is reported separately from internal chart scrolling.', 'The smoke checks are a narrow regression sample, not full option-combination or accessibility certification.'] }; const suffix = sections.length ? '-' + sections.join('-') : ''; 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-p8/browser${suffix}.json` })); await context.close(); await browser.close(); if (result.fail) process.exitCode = 1; }