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 { fileURLToPath } from 'node:url'; // Verification tooling is supplied by the environment, never shipped in Core. const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-p1'); await mkdir(output, { recursive: true }); const fixture = JSON.parse(await readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); await loadPlaywright(); const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf', '.woff2': 'font/woff2', '.png': 'image/png', '.svg': 'image/svg+xml' }; async function serve(directory) { const server = http.createServer(async (request, response) => { try { const url = new URL(request.url, 'http://127.0.0.1'); const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); const relative = path.relative(directory, file); if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; } 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)); return { server, origin: `http://127.0.0.1:${server.address().port}` }; } const results = [], errors = [], metrics = [], snapshots = []; const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); const same = (left, right) => JSON.stringify(left) === JSON.stringify(right); const clone = data => structuredClone(data); const dataWith = (data, version) => { const next = clone(data); next.metadata.dataVersion = version; return next; }; function captureErrors(page, label) { page.on('pageerror', error => errors.push({ label, type: 'pageerror', message: error.message })); page.on('console', message => { if (message.type() === 'error') errors.push({ label, type: 'console', message: message.text() }); }); } async function ready(page, transport, version) { await page.waitForFunction(({ transport, version }) => { const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React; const state = transport === 'vanilla' ? api?.chart?.getState() : api?.events?.at(-1); const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; return state?.status === 'ready' && state.dataVersion === version && shadow?.querySelector('.hm-orgchart-composition .node'); }, { transport, version }, { timeout: 20000 }); await page.evaluate(async () => { await document.fonts.ready; const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; await Promise.all([...shadow.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(image => image.complete ? Promise.resolve() : new Promise(resolve => { image.addEventListener('load', resolve, { once: true }); image.addEventListener('error', resolve, { once: true }); }))); }); } async function setData(page, transport, data) { await page.evaluate(async ({ transport, data }) => { const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React; await api.setData(data); }, { transport, data }); await ready(page, transport, data.metadata.dataVersion); } /** Transport-neutral snapshot: no generated IDs, stylesheet hashes or host URLs. */ async function snapshot(page) { return page.evaluate(() => { const host = document.querySelector('.hm-orgchart-host'); const shadow = host.shadowRoot; const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? ''; const employees = scope => [...scope.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, kind: card.dataset.cardKind, status: card.dataset.workStatus, name: text(card.querySelector('.hm-orgchart-person-name')), position: text(card.querySelector('.hm-orgchart-person-role')), jobTitle: text(card.querySelector('.hm-orgchart-job-title')), affiliation: card.querySelector('.hm-orgchart-affiliation-mark')?.dataset.affiliationId ?? null, })); return { heading: text(shadow.querySelector('.hm-orgchart-heading')), title: text(shadow.querySelector('.hm-orgchart-title')), hub: text(shadow.querySelector('.hm-orgchart-hub')), nodes: [...shadow.querySelectorAll('.node')].map(node => { const hierarchy = node.closest('.hierarchy'); const parent = hierarchy?.parentElement?.closest('.hierarchy')?.querySelector(':scope > .node'); return { id: node.dataset.organizationId, type: node.dataset.organizationType, depth: Number(node.dataset.depth), parentId: parent?.dataset.organizationId ?? null, name: text(node.querySelector('.hm-orgchart-organization-name')), employees: employees(node) }; }), topLeaders: employees(shadow.querySelector('.hm-orgchart-top-leaders') ?? document.createElement('div')), components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component), positions: shadow.querySelector('.hm-orgchart-position-summary') ? { total: text(shadow.querySelector('.hm-orgchart-position-summary .hm-orgchart-raw-total')), policy: [...shadow.querySelectorAll('.hm-orgchart-position-summary .hm-orgchart-policy')].map(text), headers: [...shadow.querySelectorAll('.hm-orgchart-position-table thead th')].map(text), rows: [...shadow.querySelectorAll('.hm-orgchart-position-table tbody tr')].map(row => ({ id: row.dataset.organizationId, name: text(row.querySelector('th')), cells: [...row.querySelectorAll('td')].map(text) })), totals: [...shadow.querySelectorAll('.hm-orgchart-position-table tfoot td')].map(text), statuses: text(shadow.querySelector('.hm-orgchart-status-totals')), } : null, affiliations: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item')].map(item => ({ id: item.dataset.affiliationId, count: text(item.querySelector('dd')), label: item.querySelector('.hm-orgchart-affiliation-mark')?.getAttribute('aria-label'), logo: item.querySelector('.hm-orgchart-affiliation-mark img')?.getAttribute('src') ?? null, })), affiliationTotal: text(shadow.querySelector('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total')), statuses: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(group => ({ status: group.dataset.workStatus, heading: text(group.querySelector('h3')), employees: employees(group), empty: text(group.querySelector('.hm-orgchart-content-empty')) })), }; }); } function expectedCounts(data) { const employees = [...new Map(data.employees.map(employee => [employee.id, employee])).values()]; const organizations = new Map(data.organizations.map(org => [org.id, org])); const positions = new Map(), affiliations = new Map(data.affiliations.map(item => [item.id, 0])); const direct = new Map(data.organizations.map(org => [org.id, 0])); const inclusive = new Map(direct), matrix = new Map(data.organizations.map(org => [org.id, new Map()])); const statuses = { active: 0, dispatched: 0, onLeave: 0 }; for (const employee of employees) { positions.set(employee.position, (positions.get(employee.position) ?? 0) + 1); affiliations.set(employee.affiliationId, (affiliations.get(employee.affiliationId) ?? 0) + 1); direct.set(employee.organizationId, direct.get(employee.organizationId) + 1); const row = matrix.get(employee.organizationId); row.set(employee.position, (row.get(employee.position) ?? 0) + 1); statuses[employee.workStatus]++; let org = organizations.get(employee.organizationId); while (org) { inclusive.set(org.id, inclusive.get(org.id) + 1); org = organizations.get(org.parentId); } } return { total: employees.length, employees, positions, affiliations, direct, inclusive, matrix, statuses }; } function checkCounts(current, data, label) { const expected = expectedCounts(data); const positionNames = current.positions.headers.slice(1, -2); check(current.positions.rows.length === data.organizations.length, `${label}: every organization has a cross-table row`); check(current.positions.rows.every(row => { const cells = row.cells.map(Number); return positionNames.every((position, index) => cells[index] === (expected.matrix.get(row.id)?.get(position) ?? 0)) && cells.at(-2) === expected.direct.get(row.id) && cells.at(-1) === expected.inclusive.get(row.id); }), `${label}: direct organization×position cells and inclusive counts match unique primary membership`, current.positions.rows); check(positionNames.every((position, index) => Number(current.positions.totals[index]) === expected.positions.get(position)) && Number(current.positions.totals.at(-2)) === expected.total && current.positions.totals.at(-1) === '—', `${label}: direct totals are disjoint; inclusive counts are not added`, current.positions.totals); check(current.positions.total.includes(`${expected.total}명`) && current.affiliationTotal.includes(`${expected.total}명`), `${label}: Position and Affiliation totals reuse the same Raw Count`); check(current.affiliations.length === data.affiliations.length && current.affiliations.every(item => parseInt(item.count) === expected.affiliations.get(item.id)), `${label}: every declared affiliation count is correct`, current.affiliations.map(({ id, count }) => ({ id, count }))); check(current.statuses.every(group => { const matching = expected.employees.filter(employee => employee.workStatus === group.status); return parseInt(group.heading.split('·').at(-1)) === matching.length && same(group.employees.map(employee => employee.id).sort(), matching.map(employee => employee.id).sort()); }), `${label}: status groups use original employee IDs without inflating total`, current.statuses.map(group => ({ status: group.status, ids: group.employees.map(employee => employee.id) }))); check(current.positions.policy.some(text => text.includes('총원 미확정')) && current.positions.policy.some(text => text.includes('조직장') && text.includes('파견') && text.includes('휴직')), `${label}: Raw inclusion policy is visible; business total remains unapproved`); } async function measure(page) { return page.evaluate(() => { const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'); const shadow = host.shadowRoot; const viewport = shadow.querySelector('.hm-orgchart-viewport'); const summary = shadow.querySelector('.hm-orgchart-summary-scroll'); const dimensions = element => element && ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX }); const textElements = [...shadow.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-organization-name')].filter(element => element.getBoundingClientRect().height > 0); const cards = [...shadow.querySelectorAll('.node')].map(element => element.getBoundingClientRect()).filter(rect => rect.height > 0); const sample = shadow.querySelector('.hm-orgchart-employee, .hm-orgchart-position-summary'); return { document: dimensions(document.documentElement), host: dimensions(host), viewport: dimensions(viewport), summary: dimensions(summary), ids: [...shadow.querySelectorAll('.hm-orgchart-domain-id')].map(element => ({ text: element.textContent, title: element.title, whiteSpace: getComputedStyle(element).whiteSpace, height: element.clientHeight, scrollHeight: element.scrollHeight })), textFits: textElements.every(element => element.scrollWidth <= element.clientWidth + 1 && element.scrollHeight <= element.clientHeight + 1), textIssues: textElements.filter(element => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1).map(element => ({ className: element.className, text: element.textContent, ...dimensions(element) })), nodeOverlap: cards.some((a, index) => cards.slice(index + 1).some(b => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1)), font: sample ? getComputedStyle(sample).fontFamily : null, foreground: sample ? getComputedStyle(sample).color : null, background: sample ? getComputedStyle(sample).backgroundColor : null, radius: sample ? getComputedStyle(sample).borderRadius : null, tokens: [...getComputedStyle(shadow.querySelector('.hm-orgchart-shell'))].filter(key => key.startsWith('--hm-orgchart-')).map(key => ({ key, value: getComputedStyle(shadow.querySelector('.hm-orgchart-shell')).getPropertyValue(key).trim() })), cssLoaded: [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet), portraitsFallback: [...shadow.querySelectorAll('.hm-orgchart-avatar')].every(avatar => avatar.textContent.trim() && !avatar.querySelector('img')), logos: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item .hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, available: mark.dataset.logoAvailable, src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })), navigationCount: shadow.querySelectorAll('nav, .hm-dashboard-header, [data-slot=sidebar], .hm-gnb, .hm-lnb').length, }; }); } async function styleIsolation(page, transport) { const evidence = await page.evaluate(async () => { const host = document.querySelector('.hm-orgchart-host'); const shadow = host.shadowRoot; const sample = shadow.querySelector('.hm-orgchart-employee[data-card-kind=employee]'); const signature = () => { const style = getComputedStyle(sample); return { foreground: style.color, background: style.backgroundColor, font: style.fontFamily, radius: style.borderRadius, border: style.borderTopColor }; }; const initial = signature(); // Document CSS must be optional; private Shadow stylesheet links stay mounted. const documentLinks = [...document.querySelectorAll('link[rel=stylesheet]')]; documentLinks.forEach(link => link.remove()); await new Promise(resolve => requestAnimationFrame(resolve)); const withoutTheme = signature(); for (const [property, value] of [['--hm-sem-bg-surface', '#ff0000'], ['--hm-sem-fg-primary', '#ff0000'], ['--hm-radius-container', '99px'], ['--hm-font-family-base', 'monospace']]) host.style.setProperty(property, value); const alteredHM = signature(); const sentinel = document.createElement('span'); sentinel.textContent = 'Host style isolation sentinel'; sentinel.style.color = 'rgb(55, 66, 77)'; sentinel.style.backgroundColor = 'rgb(88, 99, 110)'; document.body.append(sentinel); const sentinelBefore = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor }; // Public inputs intentionally remain undeclared; defaults resolve on the // shadow shell so a host input can inherit without being masked by :host. const token = '--hm-orgchart-bg-surface'; host.style.setProperty(token, 'rgb(7, 23, 41)'); const scopedOverride = signature(); const sentinelAfter = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor }; if (token) host.style.removeProperty(token); for (const property of ['--hm-sem-bg-surface', '--hm-sem-fg-primary', '--hm-radius-container', '--hm-font-family-base']) host.style.removeProperty(property); sentinel.remove(); await Promise.all(documentLinks.map(link => new Promise(resolve => { link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', resolve, { once: true }); document.head.append(link); }))); return { initial, withoutTheme, alteredHM, scopedOverride, token, sentinelBefore, sentinelAfter, restored: signature(), removedDocumentStyles: documentLinks.length }; }); metrics.push({ transport, stage: 'private-style-isolation', ...evidence }); check(same(evidence.initial, evidence.withoutTheme), `${transport}: Core style is unchanged without host HM Theme CSS`, evidence); check(same(evidence.withoutTheme, evidence.alteredHM), `${transport}: unrelated host HM color/radius/font variables cannot change private OrgChart style`, evidence); check(evidence.token && evidence.scopedOverride.background === 'rgb(7, 23, 41)' && same(evidence.sentinelBefore, evidence.sentinelAfter), `${transport}: scoped OrgChart surface override affects its instance only`, evidence); check(same(evidence.initial, evidence.restored), `${transport}: removing the scoped override restores standalone defaults`, evidence); } async function standaloneFactories(page) { const evidence = await page.evaluate(async data => { const api = window.OrgChartP1.api; const stylesheetUrls = window.OrgChartP1.stylesheetUrls; const existing = document.querySelector('.hm-orgchart-host'); const originalBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor; const results = []; for (const [factory, selector] of [['createPositionSummary', '.hm-orgchart-position-summary'], ['createAffiliationSummary', '.hm-orgchart-affiliation-summary'], ['createStatusSummary', '.hm-orgchart-status-summary']]) { const container = document.createElement('div'); container.style.maxWidth = '760px'; document.body.append(container); const events = []; const handle = api[factory]({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrl: stylesheetUrls.core, onStateChange: state => events.push(state) }); await handle.mount(); const shadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; const mounted = { state: handle.getState().status, total: handle.getState().summary?.raw.total, modules: shadow.querySelectorAll(selector).length, nodes: shadow.querySelectorAll('.node').length, foreign: shadow.querySelectorAll('.hm-orgchart-composition, .hm-orgchart-heading, .hm-orgchart-hub, .hm-orgchart-viewport').length, links: [...shadow.querySelectorAll('link')].map(link => link.getAttribute('href')), headings: shadow.textContent, }; const zero = structuredClone(data); zero.metadata.dataVersion = `${factory}-zero`; zero.employees = []; for (const org of zero.organizations) org.leaderId = null; await handle.update(zero); const currentShadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; const updated = { state: handle.getState().status, version: handle.getState().dataVersion, total: handle.getState().summary?.raw.total, employees: currentShadow.querySelectorAll('[data-employee-id]').length, affiliations: [...currentShadow.querySelectorAll('.hm-orgchart-affiliation-count')].map(element => element.textContent), emptyGroups: [...currentShadow.querySelectorAll('.hm-orgchart-content-empty')].map(element => element.textContent), }; const sibling = document.createElement('p'); sibling.textContent = 'Host-owned sibling'; container.append(sibling); handle.destroy(); handle.destroy(); results.push({ factory, mounted, updated, eventStatuses: events.map(state => state.status), destroyed: handle.getState().status, remaining: container.children.length, siblingPreserved: sibling.isConnected }); container.remove(); } const container = document.createElement('div'); document.body.append(container); const tree = api.createOrgChartTree({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrls }); await tree.mount(); const treeShadow = container.querySelector('.hm-orgchart-host').shadowRoot; const onlyTree = { ready: tree.getState().status, nodes: treeShadow.querySelectorAll('.node').length, employeeIDs: [...new Set([...treeShadow.querySelectorAll('[data-employee-id]')].map(element => element.dataset.employeeId))].sort(), forbidden: treeShadow.querySelectorAll('.hm-orgchart-heading,.hm-orgchart-hub,.hm-orgchart-composition,.hm-orgchart-position-summary,.hm-orgchart-affiliation-summary,.hm-orgchart-status-summary').length, viewport: treeShadow.querySelectorAll('.hm-orgchart-viewport').length, }; const moved = structuredClone(data); moved.metadata.dataVersion = 'standalone-tree-updated'; moved.employees.find(employee => employee.id === 'mock-employee-01').organizationId = 'solution-sales-1'; await tree.update(moved); const nextShadow = container.querySelector('.hm-orgchart-host').shadowRoot; onlyTree.updatedVersion = tree.getState().dataVersion; onlyTree.employeeMoved = !![...nextShadow.querySelectorAll('.node')].find(node => node.dataset.organizationId === 'solution-sales-1')?.querySelector('[data-employee-id="mock-employee-01"]'); tree.destroy(); onlyTree.destroyed = container.children.length === 0; container.remove(); const existingBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor; return { summaries: results, tree: onlyTree, originalBackground, existingBackground }; }, fixture); metrics.push({ stage: 'standalone-public-factories', ...evidence }); for (const item of evidence.summaries) { check(item.mounted.state === 'ready' && item.mounted.total === 41 && item.mounted.modules === 1 && item.mounted.nodes === 0 && item.mounted.foreign === 0 && item.mounted.links.length === 1, `${item.factory}: public factory mounts only its own summary without engine, heading or composition`, item.mounted); check(item.updated.state === 'ready' && item.updated.version === `${item.factory}-zero` && item.updated.total === 0 && item.updated.employees === 0 && (item.factory !== 'createAffiliationSummary' || item.updated.affiliations.length === 7 && item.updated.affiliations.every(text => text === '0명')) && (item.factory !== 'createStatusSummary' || item.updated.emptyGroups.length === 2), `${item.factory}: update refreshes shared Raw Count and removes stale cards`, item.updated); check(item.destroyed === 'destroyed' && item.remaining === 1 && item.siblingPreserved, `${item.factory}: destroy releases only owned auxiliary DOM and preserves host siblings`, item); } check(evidence.tree.ready === 'ready' && evidence.tree.nodes === 18 && evidence.tree.viewport === 1 && evidence.tree.forbidden === 0 && same(evidence.tree.employeeIDs, fixture.employees.map(employee => employee.id).sort()), 'createOrgChartTree: public standalone tree preserves all source employees with no heading/hub/summary composition', evidence.tree); check(evidence.tree.updatedVersion === 'standalone-tree-updated' && evidence.tree.employeeMoved && evidence.tree.destroyed, 'createOrgChartTree: standalone update and destroy retain engine lifecycle', evidence.tree); check(evidence.originalBackground === evidence.existingBackground, 'standalone factories do not alter the existing full-composition instance'); } const expectedOldIds = ['basic-organization', 'organization-hierarchy', 'employee-cards', 'organization-summary', 'json-data-update', 'loading-empty-error', 'responsive-container', 'multiple-instances'].map(id => `orgchart-content--${id}`); const canonicalStoryIds = ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'].map(id => `orgchart--${id}`); const internalStoryIds = [ ...['business-management', 'business-planning', 'talent-growth', 'integrated-system', 'design-planning', 'solution-sales', 'top-leader', 'organization-leader', 'employee'].map(id => `orgchart-components-cards--${id}`), ...['position-summary', 'affiliation-summary', 'status-summary'].map(id => `orgchart-components-summaries--${id}`), ]; // Canonical entries cover every component once; only the three distinct employee // kinds additionally need their internal preview. Registry preservation is static. const renderedStoryIds = [...canonicalStoryIds, ...internalStoryIds.slice(6, 9)]; const measuredGradients = [ ['#194d82', '#1d395c', 'rgb(25, 77, 130)', 'rgb(29, 57, 92)'], ['#2164a8', '#254975', 'rgb(33, 100, 168)', 'rgb(37, 73, 117)'], ['#0f6ace', '#044a94', 'rgb(15, 106, 206)', 'rgb(4, 74, 148)'], ['#8c67b6', '#5d3494', 'rgb(140, 103, 182)', 'rgb(93, 52, 148)'], ['#09a7a2', '#00847f', 'rgb(9, 167, 162)', 'rgb(0, 132, 127)'], ['#5a963c', '#3f7629', 'rgb(90, 150, 60)', 'rgb(63, 118, 41)'], ]; /** A Story metadata fix only invalidates its console checks. Preserve the full * browser.json run and recheck those five iframes plus the newly requested * actual manager sidebar, without repeating Core/React/summary/width checks. */ async function recheckStorybookConsole() { const retryResults = [], retryErrors = [], rendered = []; const checkRetry = (condition, name, detail) => retryResults.push({ name, status: condition ? 'PASS' : 'FAIL', detail }); const server = await serve(path.join(root, 'storybook-static')); let retryBrowser; let sidebar; try { retryBrowser = await launchChromium({ headless: true }); const context = await retryBrowser.newContext({ viewport: { width: 1440, height: 1080 } }); const iframe = await context.newPage(); iframe.on('pageerror', error => retryErrors.push({ surface: 'iframe', type: 'pageerror', message: error.message })); iframe.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'iframe', type: 'console', message: message.text() }); }); for (const id of canonicalStoryIds) { await iframe.goto(`${server.origin}/iframe.html?id=${id}&viewMode=story`); await iframe.waitForFunction(() => { const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot; return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet); }, undefined, { timeout: 20000 }); rendered.push(id); } checkRetry(retryErrors.filter(error => error.surface === 'iframe').length === 0 && rendered.length === 5, 'five corrected canonical Story iframes emit no console/runtime errors', { rendered, errors: retryErrors.filter(error => error.surface === 'iframe') }); const manager = await context.newPage(); manager.on('pageerror', error => retryErrors.push({ surface: 'manager', type: 'pageerror', message: error.message })); manager.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'manager', type: 'console', message: message.text() }); }); await manager.goto(`${server.origin}/?path=/story/orgchart--full-composition`); await manager.waitForSelector('#storybook-explorer-tree [data-item-id="orgchart--full-composition"]', { timeout: 20000 }); sidebar = await manager.evaluate(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].filter(row => row.dataset.itemId.startsWith('orgchart') && row.getBoundingClientRect().height > 0).map(row => ({ id: row.dataset.itemId, label: row.textContent.trim() }))); const visibleStories = sidebar.filter(row => row.id.startsWith('orgchart--')).map(row => row.id).sort(); checkRetry(same(visibleStories, [...canonicalStoryIds].sort()), 'actual Storybook sidebar exposes exactly the approved five OrgChart Story entries', sidebar); checkRetry(sidebar.every(row => !row.id.startsWith('orgchart-content') && !row.id.startsWith('orgchart-components')), 'actual default sidebar hides the original/internal reference groups while preserving their direct routes', sidebar); await manager.screenshot({ path: path.join(output, 'storybook-sidebar.png'), fullPage: true }); checkRetry(retryErrors.filter(error => error.surface === 'manager').length === 0, 'actual Storybook manager and its preview emit no console/runtime errors', retryErrors.filter(error => error.surface === 'manager')); } catch (error) { retryResults.push({ name: 'selected Storybook console/sidebar recheck completion', status: 'FAIL', detail: error.stack }); } finally { const report = { browser: retryBrowser?.version() ?? null, mode: 'storybook-console-only', preservedInitialReport: path.join(output, 'browser.json'), results: retryResults, errors: retryErrors, rendered, sidebar, limits: ['196 previously successful Core/React/component checks were not repeated', 'This run only rechecks corrected Story metadata and newly verifies the default manager sidebar'] }; const reportPath = path.join(output, 'storybook-console-recheck.json'); await writeFile(reportPath, JSON.stringify(report, null, 2)); if (retryBrowser) await retryBrowser.close(); await new Promise(resolve => server.server.close(resolve)); const failed = retryResults.filter(result => result.status === 'FAIL'); if (failed.length) process.exitCode = 1; console.log(JSON.stringify({ passed: retryResults.filter(result => result.status === 'PASS').length, failed, report: reportPath, sidebarScreenshot: path.join(output, 'storybook-sidebar.png') }, null, 2)); } } if (process.argv.includes('--storybook-console-only')) { await recheckStorybookConsole(); process.exit(process.exitCode ?? 0); } let browser; const servers = []; try { const productionStyles = await readFile(path.join(root, 'packages/org-chart/dist/style.css'), 'utf8'); const foreignHMVariables = [...productionStyles.matchAll(/var\(\s*(--hm-(?!orgchart-)[a-z0-9-]+)/g)].map(match => match[1]); check(foreignHMVariables.length === 0, 'shipped Core stylesheet references only the private OrgChart namespace rather than host HM Theme variables', [...new Set(foreignHMVariables)]); const vanilla = await serve(path.join(root, 'examples/org-chart/dist')); servers.push(vanilla); const react = await serve(path.join(root, 'examples/org-chart-react/dist')); servers.push(react); const storybook = await serve(path.join(root, 'storybook-static')); servers.push(storybook); browser = await launchChromium({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 1080 } }); const pages = new Map(); for (const [transport, origin] of [['vanilla', vanilla.origin], ['react', react.origin]]) { const page = await context.newPage(); pages.set(transport, page); captureErrors(page, transport); await page.goto(`${origin}/p1.html`); await ready(page, transport, fixture.metadata.dataVersion); const initial = await snapshot(page); snapshots.push({ transport, stage: 'initial', value: initial }); check(initial.nodes.length === 18 && expectedCounts(fixture).total === 41, `${transport}: P1 fixture contains 18 real-name organizations and 41 virtual employee IDs`); check(initial.title === fixture.presentation.title && initial.hub === fixture.presentation.hubText, `${transport}: heading and independent hub come from JSON`); check(initial.nodes.every(node => fixture.organizations.find(org => org.id === node.id)?.parentId === node.parentId), `${transport}: rendered hierarchy retains every original parentId`, initial.nodes.map(({ id, parentId }) => ({ id, parentId }))); const topEmployee = fixture.organizations.find(org => org.parentId === null)?.leaderId; check(initial.topLeaders.length === 1 && initial.topLeaders[0].id === topEmployee && initial.topLeaders[0].kind === 'topLeader' && initial.nodes.every(node => node.employees.every(employee => employee.id !== topEmployee)), `${transport}: top leader is independent and is not duplicated in the tree`); check(initial.nodes.filter(node => node.depth === 1).length === 6 && initial.components.includes('OrgChartTree'), `${transport}: six primary organization groups preserve the engine tree boundary`); check(['PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => initial.components.filter(value => value === name).length === 1), `${transport}: three independent summary modules are composed once`); checkCounts(initial, fixture, `${transport}: initial`); for (const width of [390, 768, 1280, 1440]) { await page.setViewportSize({ width, height: 1080 }); const measurement = await measure(page); metrics.push({ transport, stage: 'initial', width, ...measurement }); check(measurement.document.scrollWidth <= measurement.document.clientWidth + 1, `${transport}: ${width}px document has no horizontal overflow`, measurement.document); check(measurement.viewport.scrollWidth > measurement.viewport.clientWidth && measurement.viewport.overflowX === 'auto', `${transport}: ${width}px wide tree uses internal scrolling`, measurement.viewport); check(measurement.textFits && !measurement.nodeOverlap && measurement.ids.every(item => item.whiteSpace === 'nowrap' && item.title === item.text && item.scrollHeight <= item.height + 1), `${transport}: ${width}px text bounds, node separation and one-line IDs`, { textIssues: measurement.textIssues, overlap: measurement.nodeOverlap, ids: measurement.ids }); check(measurement.cssLoaded && measurement.tokens.length >= 10 && measurement.tokens.every(item => item.value) && measurement.font && measurement.background !== 'rgba(0, 0, 0, 0)' && measurement.radius !== '0px', `${transport}: ${width}px independent OrgChart defaults and loaded Shadow styles`, { tokens: measurement.tokens, font: measurement.font, background: measurement.background, radius: measurement.radius }); check(measurement.logos.length === 7 && measurement.logos.every(item => item.available === 'true' && item.loaded && item.src.startsWith('data:image/')), `${transport}: ${width}px all seven acquired affiliation logos load`, measurement.logos.map(({ id, available, loaded }) => ({ id, available, loaded }))); check(measurement.portraitsFallback && measurement.navigationCount === 0, `${transport}: ${width}px virtual portrait fallback and content-only UI`); await page.screenshot({ path: path.join(output, `${transport}-${width}.png`), fullPage: true }); } await page.setViewportSize({ width: 1440, height: 1080 }); await styleIsolation(page, transport); const toggle = page.locator('.hm-orgchart-toggle').first(); await toggle.focus(); await toggle.press('Enter'); await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding')); check(await toggle.getAttribute('aria-expanded') === 'false' && await toggle.evaluate(element => element.getRootNode().activeElement === element), `${transport}: keyboard Enter collapses hierarchy and keeps focus`); await toggle.press('Space'); await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding')); check(await toggle.getAttribute('aria-expanded') === 'true', `${transport}: keyboard Space expands hierarchy`); } check(same(snapshots.find(item => item.transport === 'vanilla').value, snapshots.find(item => item.transport === 'react').value), 'HTML and React initial Core DOM and automatic summary snapshots are identical'); const changed = dataWith(fixture, 'p1-browser-json-v2'); const moved = changed.employees.find(employee => employee.id === 'mock-employee-01'); moved.organizationId = 'solution-sales-1'; moved.affiliationId = 'halla'; moved.workStatus = 'onLeave'; moved.name = '가상직원 긴 이름과 콘텐츠 줄바꿈 검증 01'; moved.position = '가상 장기 프로젝트 연구 및 운영 담당 수석'; moved.jobTitle = '가상 프로젝트 담당'; changed.presentation.title = 'JSON 갱신 · 가상 직원 이동 및 상태 변경'; for (const [transport, page] of pages) { await page.locator('.hm-orgchart-summary-scroll').focus(); await setData(page, transport, changed); check(await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.classList.contains('hm-orgchart-viewport')), `${transport}: summary keyboard focus returns to stable tree after composition regeneration`); const current = await snapshot(page); snapshots.push({ transport, stage: 'changed', value: current }); check(current.title === changed.presentation.title && current.nodes.find(node => node.id === moved.organizationId)?.employees.some(employee => employee.id === moved.id && employee.name === moved.name && employee.position === moved.position), `${transport}: changed JSON moves and updates the same employee card`); checkCounts(current, changed, `${transport}: changed JSON`); for (const width of [390, 768, 1280, 1440]) { await page.setViewportSize({ width, height: 1080 }); const measurement = await measure(page); metrics.push({ transport, stage: 'long-text', width, ...measurement }); check(measurement.textFits && measurement.document.scrollWidth <= width + 1, `${transport}: ${width}px long employee name, position and job title fit with contained table scrolling`, { textIssues: measurement.textIssues, document: measurement.document, summary: measurement.summary }); } check(current.statuses.find(group => group.status === 'onLeave').employees.some(employee => employee.id === moved.id) && !current.statuses.find(group => group.status === 'dispatched').employees.some(employee => employee.id === moved.id), `${transport}: state groups update without stale employee cards`); // Flags select independent components rather than alter the employee source or counts. const cases = [ ['hide-position', { showSummary: false }, { position: false, affiliations: true, dispatched: true, onLeave: true }], ['hide-affiliation', { showAffiliations: false }, { position: true, affiliations: false, dispatched: true, onLeave: true }], ['hide-dispatched', { showDispatched: false }, { position: true, affiliations: true, dispatched: false, onLeave: true }], ['hide-onleave', { showOnLeave: false }, { position: true, affiliations: true, dispatched: true, onLeave: false }], ['hide-both-status', { showDispatched: false, showOnLeave: false }, { position: true, affiliations: true, dispatched: false, onLeave: false }], ]; for (const [name, flags, expected] of cases) { const data = dataWith(changed, `${transport}-${name}`); Object.assign(data.presentation.groups, flags); await setData(page, transport, data); const current = await snapshot(page); check(!!current.positions === expected.position && !!current.affiliations.length === expected.affiliations && current.statuses.some(group => group.status === 'dispatched') === expected.dispatched && current.statuses.some(group => group.status === 'onLeave') === expected.onLeave && current.components.includes('StatusSummary') === (expected.dispatched || expected.onLeave), `${transport}: ${name} changes only its independent component`, current.components); check(!current.positions || Number(current.positions.totals.at(-2)) === 41, `${transport}: ${name} cannot alter Raw Count`); } const zeroAffiliation = dataWith(changed, `${transport}-zero-affiliation`); for (const employee of zeroAffiliation.employees) if (employee.affiliationId === 'baron') employee.affiliationId = 'hanmac'; await setData(page, transport, zeroAffiliation); check((await snapshot(page)).affiliations.find(item => item.id === 'baron')?.count === '0명', `${transport}: zero-count declared affiliations stay visible`); const zeroStatus = dataWith(changed, `${transport}-zero-status`); for (const employee of zeroStatus.employees) employee.workStatus = 'active'; await setData(page, transport, zeroStatus); check((await snapshot(page)).statuses.every(group => group.employees.length === 0 && group.empty && group.heading.includes('0명')), `${transport}: enabled empty status groups have explicit empty content`); // A successful HTTP response containing non-image bytes triggers img.error, // avoiding expected network-console failures while testing the actual fallback. await page.route('**/p1-invalid-portrait.png', route => route.fulfill({ status: 200, contentType: 'image/png', body: 'not a png' })); const brokenImage = dataWith(changed, `${transport}-image-fallback`); brokenImage.employees.find(employee => employee.id === moved.id).profileImage = './p1-invalid-portrait.png'; await setData(page, transport, brokenImage); const brokenCards = page.locator(`.hm-orgchart-employee[data-employee-id="${moved.id}"]`); for (let index = 0; index < await brokenCards.count(); index++) await brokenCards.nth(index).scrollIntoViewIfNeeded(); await page.waitForFunction(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => !card.querySelector('.hm-orgchart-avatar img')), moved.id); check(await page.evaluate(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => card.querySelector('.hm-orgchart-avatar').textContent.trim()), moved.id), `${transport}: invalid profile images safely return to initials`); await page.unroute('**/p1-invalid-portrait.png'); await setData(page, transport, dataWith(changed, 'p1-browser-final-changed')); snapshots.push({ transport, stage: 'restored-changed', value: await snapshot(page) }); check(transport !== 'react' || await page.evaluate(() => window.OrgChartP1React.errors.length === 0), `${transport}: auxiliary regeneration has no lifecycle error`); } check(same(...snapshots.filter(item => item.stage === 'restored-changed').map(item => item.value)), 'HTML and React Core snapshots remain identical after JSON and independent visibility updates'); const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8')); const old = Object.values(index.entries).filter(entry => entry.title === 'OrgChart/Content' && entry.type === 'story'); check(old.length === 8 && same(old.map(entry => entry.id).sort(), [...expectedOldIds].sort()) && old.every(entry => entry.tags.includes('internal')), 'all original eight Storybook entries remain registered as internal without re-running their baseline suite', old.map(entry => entry.id)); check(internalStoryIds.every(id => index.entries[id]?.type === 'story' && index.entries[id].tags.includes('internal')), 'twelve isolated card/summary Stories are preserved as internal references', internalStoryIds); const visible = Object.values(index.entries).filter(entry => entry.title === 'OrgChart' && entry.type === 'story' && !entry.tags.includes('internal')).map(entry => entry.id).sort(); check(same(visible, [...canonicalStoryIds].sort()), 'canonical OrgChart offers exactly the five approved user-visible entries', visible); const sb = await context.newPage(); captureErrors(sb, 'storybook'); for (const id of renderedStoryIds) { if (!index.entries[id]) continue; await sb.goto(`${storybook.origin}/iframe.html?id=${id}&viewMode=story`); await sb.waitForFunction(() => { const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'); const shadow = host?.shadowRoot; return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet); }, undefined, { timeout: 20000 }); await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-affiliation-mark img')].every(image => image.complete)); const component = await sb.evaluate(() => { const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'), shadow = host.shadowRoot; const organizationName = shadow.querySelector('.hm-orgchart-organization-name'); const node = shadow.querySelector('.node'); return { components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component), nodeCount: shadow.querySelectorAll('.node').length, employees: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, kind: card.dataset.cardKind })), gradients: organizationName ? { background: getComputedStyle(organizationName).backgroundImage, variable: getComputedStyle(node).getPropertyValue('--hm-orgchart-group-background').trim() } : null, body: document.body.textContent, title: organizationName?.textContent, hasPosition: !!shadow.querySelector('.hm-orgchart-position-summary'), hasAffiliation: !!shadow.querySelector('.hm-orgchart-affiliation-summary'), hasStatus: !!shadow.querySelector('.hm-orgchart-status-summary'), linksLoaded: [...shadow.querySelectorAll('link')].every(link => link.sheet), }; }); snapshots.push({ transport: 'storybook', stage: id, value: component }); check(component.linksLoaded, `${id}: real Core preview and Shadow CSS load`); if (id === 'orgchart--position-summary' || id === 'orgchart--affiliation-summary') { const kind = id.endsWith('position-summary') ? 'PositionSummary' : id.endsWith('affiliation-summary') ? 'AffiliationSummary' : 'StatusSummary'; check(component.nodeCount === 0 && same(component.components, [kind]) && component.hasPosition === (kind === 'PositionSummary') && component.hasAffiliation === (kind === 'AffiliationSummary') && component.hasStatus === (kind === 'StatusSummary'), `${id}: standalone summary has exactly its Core module and no engine tree`, component.components); } else if (id.endsWith('full-composition')) { check(component.nodeCount === 18 && ['OrgChartTree', 'PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => component.components.includes(name)), `${id}: composition contains shared tree plus all independent summaries`); for (const width of [390, 768, 1280, 1440]) { await sb.setViewportSize({ width, height: 1080 }); const measurement = await measure(sb); metrics.push({ transport: 'storybook', stage: id, width, ...measurement }); check(measurement.document.scrollWidth <= width + 1 && measurement.textFits && measurement.navigationCount === 0, `${id}: ${width}px content composition has no document overflow or clipped text`, { document: measurement.document, textIssues: measurement.textIssues }); await sb.screenshot({ path: path.join(output, `${id}-${width}.png`), fullPage: true }); } await sb.setViewportSize({ width: 1440, height: 1080 }); await sb.getByRole('button', { name: 'PositionSummary 숨기기', exact: true }).click(); await sb.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-position-summary')); check(await sb.locator('.hm-orgchart-affiliation-summary').count() === 1 && await sb.locator('.hm-orgchart-status-summary').count() === 1, `${id}: Story button independently hides PositionSummary`); await sb.getByRole('button', { name: '가상 JSON v2 반영', exact: true }).click(); await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('[data-work-status=onLeave] [data-employee-id]')].some(element => element.dataset.employeeId === 'mock-employee-01')); check(await sb.locator('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total').textContent() === 'Raw Count · 전체 41명', `${id}: Story JSON update reuses 41 unique virtual employees`); } else if (id.endsWith('top-leader') || id.endsWith('organization-leader') || id.endsWith('--employee')) { const kind = id.endsWith('top-leader') ? 'topLeader' : id.endsWith('organization-leader') ? 'leader' : 'employee'; check(component.nodeCount === 0 && component.employees.length === 1 && component.employees[0].kind === kind && same(component.components, ['EmployeeCard']), `${id}: standalone ${kind} uses one actual Core employee card`, component.employees); } else if (id === 'orgchart--department-colors') { await sb.waitForFunction(() => { const values = [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-value')]; return values.length === 30 && values.every(element => element.textContent.trim()); }); const colors = await sb.evaluate(() => [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-preview[data-group-variant]')].map(row => ({ variant: Number(row.dataset.groupVariant), text: row.textContent, background: getComputedStyle(row.querySelector('[data-color-variable$="-gradient"]')).backgroundImage, variable: getComputedStyle(row).getPropertyValue('--hm-orgchart-group-background').trim(), declarations: [...row.querySelectorAll('[data-color-variable]')].map(element => ({ variable: element.dataset.colorVariable, property: element.dataset.cssProperty, applied: getComputedStyle(element).getPropertyValue(element.dataset.cssProperty), valueText: element.parentElement.querySelector('.hm-orgchart-color-value')?.textContent })), card: (() => { const card = row.querySelector('.hm-orgchart-employee'); const style = getComputedStyle(card); return { background: style.backgroundColor, color: style.color, border: style.borderTopColor }; })(), }))); check(colors.length === 6, `${id}: all six department color components are rendered`, colors); for (const color of colors) { const [start, end, rgbStart, rgbEnd] = measuredGradients[color.variant]; check(color.background.includes('linear-gradient') && color.background.includes(rgbStart) && color.background.includes(rgbEnd) && color.variable === `linear-gradient(180deg, ${start} 0%, ${end} 100%)` && color.text.includes(start) && color.text.includes(end) && color.declarations.length === 5 && color.declarations.every(item => item.variable.startsWith('--hm-orgchart-') && item.valueText?.includes('#')), `${id}: variant ${color.variant} applies exact measured gradient and documents HEX/CSS variables`, color); check(color.declarations.find(item => item.variable.endsWith('-card-background')).applied === color.card.background && color.declarations.find(item => item.variable.endsWith('-card-color')).applied === color.card.color && color.declarations.find(item => item.variable.endsWith('-border')).applied === color.card.border, `${id}: variant ${color.variant} legend values match the actual card styles`, color.card); } } else if (id === 'orgchart--logo') { const logos = await sb.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot; return { marks: [...shadow.querySelectorAll('.hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, label: mark.getAttribute('aria-label'), src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })), text: shadow.textContent, counts: shadow.querySelectorAll('.hm-orgchart-affiliation-count, .hm-orgchart-raw-total, .hm-orgchart-position-table').length }; }); check(component.nodeCount === 0 && component.components.includes('Logo') && logos.marks.length === 7 && logos.marks.every(mark => mark.loaded && mark.src.startsWith('data:image/') && mark.label) && fixture.affiliations.every(affiliation => logos.text.includes(affiliation.name)) && logos.counts === 0, `${id}: standalone Logo uses all seven named assets without statistics`, logos.marks.map(({ id, label, loaded }) => ({ id, label, loaded }))); } } await standaloneFactories(pages.get('vanilla')); await pages.get('vanilla').evaluate(() => window.OrgChartP1.chart.destroy()); check(await pages.get('vanilla').locator('.hm-orgchart-host').count() === 0, 'destroy releases composed tree and all auxiliary summary DOM'); check(await pages.get('react').evaluate(() => window.OrgChartP1React.errors.length === 0), 'React wrapper lifecycle error boundary remains clear'); check(errors.length === 0, 'targeted P1 Vanilla, React and canonical/employee Stories have no console/runtime errors', errors); } catch (error) { results.push({ name: 'P1 browser harness completion', status: 'FAIL', detail: error.stack }); } finally { const report = { browser: browser?.version() ?? null, results, errors, metrics, snapshots, limits: ['Figma Dev Mode detailed properties NOT VERIFIED', 'Workdesk pixel parity NOT VERIFIED; gradient checks verify the recorded measured header sources only', 'Screen-reader announcements and heap/GC behavior NOT VERIFIED', 'Original 69/79 browser suites deliberately not repeated'] }; await writeFile(path.join(output, 'browser.json'), JSON.stringify(report, null, 2)); if (browser) await browser.close(); for (const { server } of servers) await new Promise(resolve => server.close(resolve)); const failed = results.filter(result => result.status === 'FAIL'); if (failed.length) process.exitCode = 1; console.log(JSON.stringify({ passed: results.filter(result => result.status === 'PASS').length, failed, report: path.join(output, 'browser.json') }, null, 2)); }