Files
shkim4 f4ddd5ac4b
Deploy Storybook / deploy (push) Successful in 59s
docs(storybook): make Expanded OrgChart a preview-only page
Remove the data, layout, interaction and employee-popup setting panels
from the Expanded OrgChart Story. The chart now renders a fixed sample
(7 companies x 20, TB, all expanded, collapse/zoom/pan on, Drag & Drop
off, HM Dialog) right under the page title. Story ID, OrgChart packages
and public API are unchanged.

Align the Story browser checks (P7, P8, P9, P9.2-P9.5, geometry, popup
parity, hierarchy, HM Story) with the preview: setting-only scenarios
are removed, chart rendering, collapse, Zoom/Pan/Fit and HM Dialog checks
run on the fixed state. Move/Drag & Drop coverage stays in the Vanilla
Core checks (verify-p9-3-core, verify-p9-4-core).

Verified: tsc, test:guard 94/94, node --check on 11 scripts, changed
browser sections PASS. Pre-existing P7 story-menu, P8 Department
long-name, P9 Expanded tb/runner failures remain separate issues.
verify-p9-5-popup-parity was not run (needs the Vanilla example server).
2026-10-22 17:51:21 +09:00

469 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// P7 change-only integration verification. Historical suites are not replayed.
import fs from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import http from 'node:http';
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-p7');
await fs.mkdir(output, { recursive: true });
const template = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded-large.json'), 'utf8'));
const small = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded-small.json'), 'utf8'));
const fixed = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
const results = [], measurements = [], errors = [];
const only = process.argv.slice(2);
const run = section => (!only.length && !['repair', 'react-repair', 'options-tail', 'story-menu', 'lr-reference', 'lr-residue', 'performance', 'dialog-narrow'].includes(section)) || only.includes(section);
const check = (pass, name, detail) => {
results.push({ name, status: pass ? 'PASS' : 'FAIL', detail });
writeFileSync(path.join(output, 'progress.json'), JSON.stringify({ results }, null, 2));
if (!pass) console.log('FAIL', name, JSON.stringify(detail));
};
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf', '.json': 'application/json', '.svg': 'image/svg+xml' };
const server = http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
if (pathname === '/favicon.ico') return response.writeHead(204).end();
if (pathname === '/shell') return response.writeHead(200, { 'Content-Type': 'text/html' }).end('<!doctype html><html lang="ko"><head><link rel="icon" href="data:,"><style>@font-face{font-family:"Noto Sans KR";src:url(/public/fonts/NotoSansKR-variable.ttf);font-weight:100 900}body{margin:0}#chart{width:100%}</style></head><body><main id="chart"></main></body></html>');
const file = path.resolve(root, pathname.startsWith('/assets/') ? 'test-results/org-chart-p7/react-build/dist' + pathname : '.' + pathname);
const relative = path.relative(root, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }).end(await fs.readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => { if (m.type() === 'error') errors.push({ message: m.text(), location: m.location() }); });
const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); });
async function ready(count) {
await page.waitForFunction(count => {
const s = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return s && (count === undefined || s.querySelectorAll('.hm-orgchart-employee').length === count) && [...s.querySelectorAll('link')].every(link => link.sheet) && !s.querySelector('.hm-orgchart-shell[aria-busy="true"]');
}, count);
await settle();
}
async function mount(data, options = {}, baseline = false) {
await page.goto(origin + '/shell');
await page.evaluate(async ({ origin, data, options, baseline }) => {
const base = origin + (baseline ? '/test-results/org-chart-p7/baseline-dist' : '/packages/org-chart/dist');
window.mod = await import(base + '/index.js');
window.handle = window.mod.createOrgChart({ container: document.getElementById('chart'), dataSource: new window.mod.JsonOrgChartDataSource(data), stylesheetUrls: { engine: base + '/engine.css', core: base + '/style.css' }, ...options });
const started = performance.now();
await window.handle.mount();
window.mountMs = performance.now() - started;
}, { origin, data, options, baseline });
await ready();
}
async function example(scope, employeeCount, companyIds) {
return page.evaluate(({ template, scope, employeeCount, companyIds }) => window.mod.createOrgChartExampleDataset(template, { scope, employeeCount, companyIds }), { template, scope, employeeCount, companyIds });
}
async function measure() {
return page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
const box = e => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; };
const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => {
const cs = getComputedStyle(c), a = c.closest('.hm-orgchart-organization'), p = a?.querySelector(`path[data-employee-id="${c.dataset.employeeId}"]`);
let x = 0, y = 0, cur = c;
while (a && cur && cur !== a) { x += cur.offsetLeft; y += cur.offsetTop; cur = cur.offsetParent; }
const d = c.dataset.connectionDirection;
const target = d === 'left' ? [x, y + c.offsetHeight / 2] : d === 'right' ? [x + c.offsetWidth, y + c.offsetHeight / 2] : [x + c.offsetWidth / 2, y];
return { id: c.dataset.employeeId, company: c.dataset.companyId, kind: c.dataset.cardKind, size: [c.offsetWidth, c.offsetHeight], box: box(c), name: c.querySelector('.hm-orgchart-person-name')?.textContent, role: c.querySelector('.hm-orgchart-person-role')?.textContent, asset: c.dataset.cardAsset, direction: d, border: [cs.borderTopColor, cs.borderRightColor, cs.borderBottomColor, cs.borderLeftColor], background: cs.backgroundImage, endpointError: p ? Math.hypot(Number(p.dataset.endX) - target[0], Number(p.dataset.endY) - target[1]) : null };
});
const v = s.querySelector('.hm-orgchart-viewport'), f = s.querySelector('.hm-orgchart-forest');
return { cards, nodes: [...s.querySelectorAll('.node[data-depth]')].map(n => ({ id: n.dataset.organizationId, parent: n.dataset.parentId, depth: Number(n.dataset.depth), company: n.dataset.companyId, box: box(n), article: box(n.querySelector('.hm-orgchart-organization')), collapsed: n.dataset.collapsed })), titles: [...s.querySelectorAll('.hm-orgchart-organization-name')].map(t => ({ text: t.textContent, background: getComputedStyle(t).backgroundColor, color: getComputedStyle(t).color, company: t.closest('[data-company-id]')?.dataset.companyId })), viewport: box(v), forest: box(f), scroll: [v.scrollLeft, v.scrollTop, v.scrollWidth, v.clientWidth, v.scrollHeight, v.clientHeight], document: [document.documentElement.scrollWidth, innerWidth, document.documentElement.scrollHeight, document.documentElement.clientHeight], layout: s.querySelector('.hm-orgchart-expanded')?.dataset.autoLayout, paths: [...s.querySelectorAll('.hm-orgchart-organization-connectors path')].map(p => ({ ...p.dataset, d: p.getAttribute('d') })), heap: performance.memory?.usedJSHeapSize, domCount: s.querySelectorAll('*').length };
});
}
const overlap = boxes => boxes.some((a, i) => boxes.slice(i + 1).some(b => Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.5 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.5));
const fit = m => m.forest.x >= m.viewport.x - 1 && m.forest.y >= m.viewport.y - 1 && m.forest.x + m.forest.width <= m.viewport.x + m.viewport.width + 1 && m.forest.y + m.forest.height <= m.viewport.y + m.viewport.height + 1;
const endpoint = m => m.cards.every(c => c.endpointError !== null && c.endpointError < 1);
const border = m => m.cards.every(c => { const i = { top: 0, right: 1, left: 3 }[c.direction]; return i === undefined ? c.border.every(b => b === c.border[2]) : c.border[i] !== c.border[2] && c.border.every((b, j) => j === i || b === c.border[2]); });
async function screenshot(name) { await page.screenshot({ path: path.join(output, name + '.png'), fullPage: true }); }
async function setLayout(options) { await page.evaluate(options => window.handle.setAutoLayout(options), options); await ready(); }
async function hmPopup(name) {
const first = page.locator('.hm-orgchart-employee').first();
const id = await first.getAttribute('data-employee-id');
await first.focus(); await first.press('Enter');
const d = page.locator('[data-component="HMOrgChartEmployeeDialog"]');
await d.waitFor();
const details = await d.evaluate(d => ({ role: d.getAttribute('role'), employee: d.dataset.employeeId, focus: d.contains(document.activeElement), unscaled: !d.closest('.hm-orgchart-forest'), bodyPortal: d.parentElement === document.body, text: d.textContent, transform: getComputedStyle(d).transform }));
check(details.employee === id && details.role === 'dialog' && details.focus && details.unscaled, name + ': actual HM Dialog, exact employee, Portal and focus', details);
check(await page.locator('dialog[open]').count() === 0 && await page.getByRole('dialog').count() === 1, name + ': only one popup');
await screenshot(name.replaceAll(' ', '-') + '-dialog');
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
check(await d.evaluate(d => d.contains(document.activeElement)), name + ': keyboard focus remains in Dialog');
await page.keyboard.press('Escape'); await d.waitFor({ state: 'hidden' }); await settle();
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), name + ': ESC returns focus to employee');
await first.press('Space'); await d.waitFor();
await d.getByRole('button', { name: '닫기', exact: true }).last().click(); await d.waitFor({ state: 'hidden' }); await settle();
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), name + ': close button returns focus');
}
try {
if (run('core')) {
// Exact old fixture checks compare only unchanged layout/card geometry, since white titles predate P7.
await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' }, true); const oldTB = await measure();
await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' }); const newTB = await measure();
const signature = m => ({ cards: m.cards.map(c => [c.id, c.box, c.size, c.role, c.name]), nodes: m.nodes.map(n => [n.id, n.box, n.article]) });
check(JSON.stringify(signature(oldTB)) === JSON.stringify(signature(newTB)), 'TB existing fixture geometry remains unchanged');
for (const count of [20, 100, 300]) {
const data = await example('total', count);
await page.evaluate(data => window.handle.update(data), data); await ready(count);
const m = await measure();
check(m.cards.length === count && new Set(m.cards.map(c => c.id)).size === count && new Set(m.nodes.map(n => n.company)).size === 7, `Total ${count}: all seven companies and unique employees`);
check(!overlap(m.cards.map(c => c.box)) && !overlap(m.nodes.map(n => n.article)), `Total ${count}: no card or organization overlap`);
check(fit(m), `Total ${count}: data update refreshes Fit bounds`, { forest: m.forest, viewport: m.viewport });
}
for (const count of [20, 100, 300]) {
const data = await example('per-company', count, ['saman']);
await page.evaluate(data => window.handle.update(data), data); await ready(count);
check((await measure()).cards.every(c => c.company === 'saman'), `Single company ${count}: exact selected company`);
}
const data = await example('per-company', 100, ['hanmac', 'saman']);
await page.evaluate(data => window.handle.update(data), data); await ready(200);
check(new Set((await measure()).cards.map(c => c.company)).size === 2, 'Two companies ×100 render exactly 200');
const all = await example('total', 300);
await page.evaluate(data => window.handle.update(data), all); await ready(300);
const dims = (await measure()).cards.map(c => c.size);
for (const options of [{ type: 'lr' }, { type: 'tb' }, { type: 'hybrid', hybridStartDepth: 2 }, { type: 'tb' }, { type: 'lr' }, { type: 'hybrid', hybridStartDepth: 3 }, { type: 'hybrid', hybridStartDepth: 4 }]) {
const started = Date.now(); await setLayout(options); const ms = Date.now() - started; const m = await measure();
check(m.cards.length === 300 && new Set(m.cards.map(c => c.id)).size === 300, `${JSON.stringify(options)}: preserves complete data`);
check(!overlap(m.cards.map(c => c.box)) && !overlap(m.nodes.map(n => n.article)), `${JSON.stringify(options)}: no card or group overlap`);
check(fit(m), `${JSON.stringify(options)}: complete Fit`, { forest: m.forest, viewport: m.viewport });
check(endpoint(m) && border(m), `${JSON.stringify(options)}: connector endpoints and single accent side agree`);
check(JSON.stringify(m.cards.map(c => c.size)) === JSON.stringify(dims), `${JSON.stringify(options)}: card dimensions preserved`);
if (options.type === 'lr') {
check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.x + p.article.width < n.article.x)), 'LR: every child to the right of its parent');
const parents = new Set(m.nodes.map(n => n.parent).filter(Boolean));
check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), 'LR: siblings stack vertically');
check(m.cards.every(c => c.direction === 'left'), 'LR: cards use left incoming accent');
} else {
check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.y + p.article.height < n.article.y)), `${JSON.stringify(options)}: each child remains under its own parent`);
if (options.type === 'hybrid') {
const parents = new Set(m.nodes.filter(n => n.depth + 1 >= options.hybridStartDepth).map(n => n.parent).filter(Boolean));
check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), `Hybrid Depth ${options.hybridStartDepth}: siblings below own parent form a vertical list`);
}
}
measurements.push({ name: JSON.stringify(options), switchAndSettleMs: ms, value: m });
if (options.type === 'lr' || options.type === 'hybrid') await screenshot(options.type + '-' + (options.hybridStartDepth ?? '300'));
}
await setLayout({ type: 'tb' });
const rootId = (await measure()).nodes.find(n => !n.parent).id;
let toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${rootId}"]`);
await toggle.focus(); await toggle.press('Enter'); await ready();
const collapsedCount = (await measure()).cards.length;
for (const type of ['lr', 'hybrid', 'tb']) { await setLayout({ type }); check((await measure()).cards.length === collapsedCount, `Collapse state preserved switching to ${type}`); }
toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${rootId}"]`); await toggle.focus(); await toggle.press('Space'); await ready(300);
check(await toggle.getAttribute('aria-expanded') === 'true', 'Keyboard expands group after mode transitions');
for (const depth of [1, 2, 3]) {
await setLayout({ type: 'hybrid', initialVisibleDepth: depth }); const m = await measure();
check(m.nodes.every(n => n.depth + 1 <= depth), `Initial visible Depth ${depth}: no deeper group displayed`);
}
await setLayout({ type: 'lr', initialVisibleDepth: 'all' }); await ready(300);
await page.evaluate(() => window.handle.setSelectedCompanies(['hanmac', 'saman'])); await ready();
check(new Set((await measure()).nodes.map(n => n.company)).size === 2, 'Company selection independent of LR');
await setLayout({ type: 'hybrid', hybridStartDepth: 2 });
check(new Set((await measure()).nodes.map(n => n.company)).size === 2, 'Selected companies preserved changing to Hybrid');
await page.evaluate(() => window.handle.setSelectedCompanies([])); await ready(0);
check(await page.locator('.hm-orgchart-expanded-empty').count() === 1, 'Empty company selection safe');
await page.evaluate(() => window.handle.setSelectedCompanies()); await ready(300);
for (const type of ['tb', 'lr', 'hybrid']) {
await setLayout({ type }); const m = await measure();
check(m.titles.every(t => t.color === 'rgb(255, 255, 255)'), `${type}: all company title text remains white`);
const v = page.locator('.hm-orgchart-viewport'), vb = await v.boundingBox();
const before = await page.evaluate(() => window.handle.getViewportState());
await page.mouse.move(vb.x + 10, vb.y + 10); await page.mouse.down(); await page.mouse.move(vb.x + 50, vb.y + 30); await page.mouse.up();
const after = await page.evaluate(() => window.handle.getViewportState());
check(after.x !== before.x && after.y !== before.y, `${type}: trusted pointer Pan`);
await page.getByRole('button', { name: '100% 복원', exact: true }).click();
await page.getByRole('button', { name: '확대', exact: true }).click();
check((await page.evaluate(() => window.handle.getViewportState())).scale > 1, `${type}: Zoom independent`);
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
check(fit(await measure()), `${type}: toolbar Fit`);
}
// Vanilla popup remains outside the scaled forest, closes with ESC and restores card.
const first = page.locator('.hm-orgchart-employee').first(), id = await first.getAttribute('data-employee-id');
await first.focus(); await first.press('Enter'); await page.locator('dialog[open]').waitFor();
check(await page.locator('dialog[open]').evaluate(d => !d.closest('.hm-orgchart-forest') && getComputedStyle(d).transform === 'none' && d.contains(d.getRootNode().activeElement)), 'Vanilla default popup focus and unscaled layer');
await page.keyboard.press('Escape'); await settle();
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), 'Vanilla ESC restores employee focus');
// Higher scale has its own measurement; P5's 300 benchmark is not used.
const huge = await example('per-company', 300);
await mount(huge, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: { type: 'lr' } }); await ready(2100);
const hugeLR = await measure();
check(!overlap(hugeLR.cards.map(c => c.box)) && fit(hugeLR), '2100 LR: full rendering, no card overlap and Fit');
measurements.push({ name: '2100 LR initial full', mountMs: await page.evaluate(() => window.mountMs), value: hugeLR }); await screenshot('2100-lr');
const largeStart = Date.now(); await setLayout({ type: 'hybrid', hybridStartDepth: 3 }); await ready(2100); const hugeHybrid = await measure();
check(!overlap(hugeHybrid.cards.map(c => c.box)) && fit(hugeHybrid), '2100 Hybrid: full rendering, no card overlap and Fit');
measurements.push({ name: '2100 Hybrid full', switchAndSettleMs: Date.now() - largeStart, value: hugeHybrid });
await setLayout({ type: 'hybrid', initialVisibleDepth: 2 }); const limited = await measure();
check(limited.cards.length < 2100 && limited.nodes.every(n => n.depth < 2), '2100 independent visible depth reduces initial rendering');
measurements.push({ name: '2100 Depth2', value: limited });
// Minimum Fixed regression from captured pre-P7 public distribution.
await mount(fixed, { displayMode: 'readOnly' }, true); const beforeFixed = await measure();
await mount(fixed, { displayMode: 'readOnly' }); const afterFixed = await measure();
const fixedSig = m => m.cards.map(c => [c.id, c.box, c.size, c.name, c.role, c.asset, c.border, c.background]);
check(JSON.stringify(fixedSig(beforeFixed)) === JSON.stringify(fixedSig(afterFixed)), 'Fixed card geometry/SVG/text/gradient/border unchanged');
check(await page.locator('.hm-orgchart-expanded-toggle').count() === 0, 'Fixed contains no new Auto controls');
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('dialog[open]').waitFor(); await page.keyboard.press('Escape');
check(await page.locator('dialog[open]').count() === 0, 'Fixed original popup retains open/ESC'); await screenshot('fixed-1440');
}
if (run('repair')) {
await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' });
for (const count of [20, 100, 300]) {
const data = await example('total', count);
await page.evaluate(data => window.handle.update(data), data); await ready(count);
check(fit(await measure()), `Total ${count}: data update refreshes Fit bounds`);
}
for (const options of [{ type: 'lr' }, { type: 'hybrid', hybridStartDepth: 2 }, { type: 'hybrid', hybridStartDepth: 3 }, { type: 'hybrid', hybridStartDepth: 4 }, { type: 'tb', hybridOrganizationIds: ['hanmac-root'] }]) {
await setLayout(options); const m = await measure();
check(!overlap(m.cards.map(c => c.box)) && !overlap(m.nodes.map(n => n.article)), `Corrected ${JSON.stringify(options)}: no group/card overlap`);
check(fit(m) && endpoint(m) && border(m), `Corrected ${JSON.stringify(options)}: Fit, card endpoints and accents`);
if (options.type === 'lr') {
check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.x + p.article.width < n.article.x)), 'Corrected LR: every child right of its parent');
const parents = new Set(m.nodes.map(n => n.parent).filter(Boolean));
check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), 'LR: siblings stack vertically');
} else {
check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.y + p.article.height < n.article.y)), `${JSON.stringify(options)}: each child remains under its own parent`);
const parents = new Set(m.nodes.filter(n => options.type === 'hybrid' ? n.depth + 1 >= options.hybridStartDepth : n.company === 'hanmac').map(n => n.parent).filter(Boolean));
check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), `Hybrid Depth ${options.hybridStartDepth ?? 'group'}: siblings below own parent form a vertical list`);
}
const pathValid = await page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
return [...s.querySelectorAll('.hm-orgchart-tree')].every(tree => {
const nodes = [...tree.querySelectorAll('.node[data-organization-id]')];
const paths = [...tree.querySelectorAll('.hm-orgchart-organization-connectors path')];
const scale = Number(s.querySelector('.hm-orgchart-viewport').dataset.scale);
return paths.length === nodes.filter(n => n.dataset.parentId).length && paths.every(p => {
const node = nodes.find(n => n.dataset.organizationId === p.dataset.organizationId);
if (!node || node.dataset.parentId !== p.dataset.parentId) return false;
const r = node.querySelector('.hm-orgchart-organization-name').getBoundingClientRect(), t = tree.getBoundingClientRect();
const nums = p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number);
const direction = p.dataset.direction;
const x = direction === 'left' ? nums.at(-1) : nums.at(-2);
const y = direction === 'left' ? nums.at(-2) : nums.at(-1);
return Math.abs(x - ((r.left - t.left) / scale + (direction === 'left' ? 0 : node.querySelector('.hm-orgchart-organization-name').offsetWidth / 2))) < 1
&& Math.abs(y - ((r.top - t.top) / scale + (direction === 'left' ? node.querySelector('.hm-orgchart-organization-name').offsetHeight / 2 : 0))) < 1;
});
});
});
check(pathValid, `Corrected ${JSON.stringify(options)}: actual organization paths follow own parent and heading endpoints`);
measurements.push({ name: 'Corrected ' + JSON.stringify(options), value: m });
await screenshot('corrected-' + options.type + '-' + (options.hybridStartDepth ?? 'group'));
}
const huge = await example('per-company', 300);
await mount(huge, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: { type: 'lr' } }); await ready(2100);
const m = await measure();
check(!overlap(m.cards.map(c => c.box)) && fit(m), 'Corrected 2100 LR: full rendering/no overlap/Fit');
measurements.push({ name: 'Corrected 2100 LR full', mountMs: await page.evaluate(() => window.mountMs), value: m });
await screenshot('2100-lr');
const started = Date.now(); await setLayout({ type: 'hybrid', hybridStartDepth: 3 }); await ready(2100);
const hybrid = await measure();
check(!overlap(hybrid.cards.map(c => c.box)) && fit(hybrid), 'Corrected 2100 Hybrid: full rendering/no overlap/Fit');
measurements.push({ name: 'Corrected 2100 Hybrid full', switchAndSettleMs: Date.now() - started, value: hybrid });
await screenshot('2100-hybrid');
}
if (run('story')) {
// Expanded is preview-only (2026-10-22): the fixed 7 companies x 20 TB sample replaces the
// removed data/scope/company/layout/Depth/popup settings scenarios.
await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');
await ready(140);
check(new Set((await measure()).nodes.map(n => n.company)).size === 7 && (await measure()).layout === 'tb', 'Story preview: seven companies in fixed TB');
await hmPopup('Story HM');
const rootId = (await measure()).nodes.find(n => !n.parent).id;
const toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${rootId}"]`);
await toggle.focus(); await toggle.press('Enter'); await ready();
check(await toggle.getAttribute('aria-expanded') === 'false' && await page.locator('.hm-orgchart-employee').count() < 140, 'Story preview: group collapse');
await toggle.focus(); await toggle.press('Enter'); await ready(140);
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: width === 390 ? 844 : 1100 }); await settle();
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
const m = await measure();
check(m.document[0] <= width && m.scroll[0] === 0 && m.scroll[1] === 0, `Story ${width}px: no document overflow/hidden viewport scroll`, { document: m.document, scroll: m.scroll });
measurements.push({ name: `Story ${width}px`, value: m });
await screenshot('story-' + width);
}
check(await page.getByRole('alert').count() === 0, 'Story has no lifecycle error alert');
}
if (run('react')) {
await page.goto(origin + '/test-results/org-chart-p7/react-build/dist/index.html'); await ready(20);
await hmPopup('React production');
for (const type of ['lr', 'hybrid']) {
await page.evaluate(type => window.p7.setAutoLayout({ type }), type); await ready(20);
await hmPopup('React ' + type);
}
// Layout replacement while Dialog is open preserves selected identity and restores replacement card.
const id = await page.locator('.hm-orgchart-employee').first().getAttribute('data-employee-id');
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor();
await page.evaluate(() => window.p7.setAutoLayout({ type: 'tb' })); await settle();
check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').getAttribute('data-employee-id') === id, 'React layout change retains popup employee identity');
await page.keyboard.press('Escape'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor({ state: 'hidden' }); await settle();
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), 'React mode rerender returns focus to replacement card');
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor();
await page.evaluate(() => window.p7.setCompanies(['saman'])); await settle();
check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').count() === 0, 'React company disappearance clears external popup');
await page.evaluate(() => window.p7.setCompanies(undefined)); await ready(20);
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor();
await page.evaluate(() => window.p7.setInput({ scope: 'per-company', employeeCount: 100, companyIds: ['saman'] })); await ready(100);
await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor({ state: 'hidden' });
check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').count() === 0, 'React source change clears stale popup');
const m = await measure(); check(m.cards.every(c => c.company === 'saman'), 'React generated100: source identity matches shared Vanilla contract');
measurements.push({ name: 'React generated100', value: m });
}
if (run('react-repair')) {
await page.goto(origin + '/test-results/org-chart-p7/react-build/dist/index.html'); await ready(20);
const id = await page.locator('.hm-orgchart-employee').first().getAttribute('data-employee-id');
await page.locator('.hm-orgchart-employee').first().press('Enter'); const d = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await d.waitFor();
await page.evaluate(() => window.p7.setAutoLayout({ type: 'lr' })); await settle();
await page.keyboard.press('Escape'); await d.waitFor({ state: 'hidden' }); await settle();
check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), 'React mode rerender returns focus to replacement card');
await page.locator('.hm-orgchart-employee').first().press('Enter'); await d.waitFor();
await page.evaluate(() => window.p7.setInput({ scope: 'per-company', employeeCount: 100, companyIds: ['saman'] })); await ready(100); await d.waitFor({ state: 'hidden' });
check(await d.count() === 0, 'React source change clears stale popup');
}
if (run('options')) {
await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' });
for (const type of ['tb', 'lr', 'hybrid']) {
await setLayout({ type });
for (const config of [{ collapse: false, zoom: true, pan: true }, { collapse: true, zoom: false, pan: true }, { collapse: true, zoom: true, pan: false }, { collapse: false, zoom: false, pan: false }]) {
const before = await page.evaluate(() => window.handle.getViewportState());
await page.evaluate(config => window.handle.setInteractions(config), config); await ready(20);
const after = await page.evaluate(() => window.handle.getViewportState());
const v = page.locator('.hm-orgchart-viewport');
const root = page.locator('.hm-orgchart-expanded-toggle').first();
check(await root.isDisabled() === !config.collapse && await page.getByRole('button', { name: '확대', exact: true }).isDisabled() === !config.zoom && await v.getAttribute('data-pan-enabled') === String(config.pan), `${type} independent ${JSON.stringify(config)}: only requested capabilities disabled`);
check(JSON.stringify(before) === JSON.stringify(after), `${type} independent ${JSON.stringify(config)}: setter retains viewport state`);
const box = await v.boundingBox(); const panBefore = await page.evaluate(() => window.handle.getViewportState());
await page.mouse.move(box.x + 8, box.y + 8); await page.mouse.down(); await page.mouse.move(box.x + 60, box.y + 30); await page.mouse.up();
const panAfter = await page.evaluate(() => window.handle.getViewportState());
check((panAfter.x !== panBefore.x || panAfter.y !== panBefore.y) === config.pan, `${type} independent ${JSON.stringify(config)}: trusted Pan respects flag`);
if (!config.zoom) {
await page.keyboard.down('Control'); await page.mouse.wheel(0, -80); await page.keyboard.up('Control'); await settle();
check((await page.evaluate(() => window.handle.getViewportState())).scale === panAfter.scale, `${type} disabled Zoom ignores Ctrl-wheel`);
}
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle();
check(fit(await measure()), `${type} independent ${JSON.stringify(config)}: Fit remains usable`);
}
await page.evaluate(() => window.handle.setInteractions({ collapse: true, zoom: true, pan: true })); await ready(20);
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('dialog[open]').waitFor(); await page.keyboard.press('Escape');
check(await page.locator('dialog[open]').count() === 0, `${type}: popup works independently of feature flags`);
}
await page.evaluate(() => { window.handle.zoomTo(1); });
await page.evaluate(() => window.handle.setInteractions({ pan: false, zoom: true, collapse: true })); await ready(20);
await page.locator('.hm-orgchart-employee').last().focus(); await settle();
const focus = await page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot, v = s.querySelector('.hm-orgchart-viewport'), c = s.activeElement;
const r = c.getBoundingClientRect(), b = v.getBoundingClientRect();
return { visible: r.left >= b.left && r.right <= b.right && r.top >= b.top && r.bottom <= b.bottom, scale: v.dataset.scale, scroll: [v.scrollLeft, v.scrollTop] };
});
check(focus.visible && focus.scale === '1' && focus.scroll.every(n => n === 0), 'Pan disabled: keyboard reveals offscreen card at unchanged Zoom with no hidden scroll', focus);
// Expanded is preview-only (2026-10-22): its collapse/Zoom/Pan checkboxes and popup selector were removed.
await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(140);
await hmPopup('Final tb');
}
if (run('options') || run('options-tail')) {
if (run('options-tail')) {
await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(140);
}
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: width === 390 ? 844 : 1100 }); await settle();
await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const m = await measure();
check(m.document[0] <= width && m.scroll[0] === 0 && m.scroll[1] === 0 && fit(m), `Final Story ${width}px: added options fit and no overflow`, { document: m.document, scroll: m.scroll, forest: m.forest, viewport: m.viewport });
measurements.push({ name: `Final Story ${width}px`, value: m }); await screenshot('story-' + width);
}
const presetColors = { hanmac: 'rgb(203, 46, 40)', saman: 'rgb(240, 131, 0)', 'jangheon-industry': 'rgb(69, 128, 166)', jangheon: 'rgb(29, 65, 141)', ptc: 'rgb(132, 51, 142)', halla: 'rgb(0, 104, 183)', baron: 'rgb(12, 54, 127)' };
const colors = (await measure()).titles;
check(colors.every(t => t.color === 'rgb(255, 255, 255)' && t.background === presetColors[t.company]), 'Seven company titles preserve exact preset colors and requested white text', colors);
const title = page.locator('.hm-orgchart-organization-name').first();
await page.locator('.hm-orgchart-expanded-toggle').first().hover();
check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains on hover');
await page.locator('.hm-orgchart-expanded-toggle').first().focus();
check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains on keyboard focus');
await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter'); await settle();
check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains collapsed');
await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter'); await settle();
check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains expanded');
}
if (run('options-tail') || run('story-menu')) {
const index = await (await page.request.get((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/index.json')).json();
const names = Object.values(index.entries).filter(e => e.title === 'OrgChart' && e.type === 'story' && !e.tags.includes('internal') && !e.tags.includes('verification')).map(e => e.name).sort();
check(JSON.stringify(names) === JSON.stringify(['Department Colors', 'Expanded OrgChart', 'Full Composition', 'Logo · Personnel Summary'].sort()), 'Story menu retains exactly the original four user entries', names);
}
if (run('lr-reference') || run('lr-residue')) {
await mount(template, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: { type: 'lr' } }); await ready(300);
if (run('lr-reference')) {
const upright = await page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
const elements = [...s.querySelectorAll('.orgchart, .node, .hm-orgchart-organization, .hm-orgchart-employee, .hm-orgchart-avatar, .hm-orgchart-person-name, .hm-orgchart-person-role')];
return { l2r: s.querySelectorAll('.orgchart.l2r').length, rotated: elements.filter(e => getComputedStyle(e).transform !== 'none').map(e => [e.className, getComputedStyle(e).transform]), photos: [...s.querySelectorAll('.hm-orgchart-avatar')].every(e => e.offsetWidth === 36 && e.offsetHeight === 36) };
});
check(upright.l2r === 7 && upright.rotated.length === 0 && upright.photos, 'LR reference: actual l2r engine class on all seven trees; photos/text/card remain upright', upright);
const route = async () => page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot, scale = Number(s.querySelector('.hm-orgchart-viewport').dataset.scale);
return [...s.querySelectorAll('.hm-orgchart-tree')].every(tree => {
const nodes = [...tree.querySelectorAll('.node[data-organization-id]')], paths = [...tree.querySelectorAll('.hm-orgchart-organization-connectors path')], t = tree.getBoundingClientRect();
return paths.length === nodes.filter(n => n.dataset.parentId).length && paths.every(p => {
const node = nodes.find(n => n.dataset.organizationId === p.dataset.organizationId), parent = nodes.find(n => n.dataset.organizationId === p.dataset.parentId);
if (!node || !parent || node.dataset.parentId !== parent.dataset.organizationId) return false;
const start = parent.querySelector('.hm-orgchart-organization-name').getBoundingClientRect(), end = node.querySelector('.hm-orgchart-organization-name').getBoundingClientRect();
const values = p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number);
return Math.abs(values[0] - (start.right - t.left) / scale) < 1 && Math.abs(values[1] - (start.top + start.height / 2 - t.top) / scale) < 1
&& Math.abs(values.at(-1) - (end.left - t.left) / scale) < 1 && Math.abs(values.at(-2) - (end.top + end.height / 2 - t.top) / scale) < 1;
});
});
});
const branch = page.locator('.node[data-organization-id="hanmac-division-0"] > .hm-orgchart-organization .hm-orgchart-expanded-toggle').first();
const before = await measure(); await branch.press('Enter'); await ready(); const closed = await measure();
check(closed.cards.length < before.cards.length && closed.nodes.length < before.nodes.length && await route() && !overlap(closed.nodes.map(n => n.article)) && endpoint(closed) && border(closed), 'LR reference: collapsed branch removes descendants and recalculates both connector endpoints and layout');
await branch.press('Enter'); await ready(300); const opened = await measure();
check(await route() && !overlap(opened.nodes.map(n => n.article)) && endpoint(opened) && border(opened), 'LR reference: reopened branch updates both parent/child endpoints without overlap');
}
await setLayout({ type: 'tb' }); await setLayout({ type: 'hybrid', hybridStartDepth: 3 }); await setLayout({ type: 'lr' });
const clean = await page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
return { trees: s.querySelectorAll('.orgchart').length, l2r: s.querySelectorAll('.orgchart.l2r').length, vertical: s.querySelectorAll('.nodes.vertical').length, svgs: s.querySelectorAll('.hm-orgchart-organization-connectors').length, paths: s.querySelectorAll('.hm-orgchart-organization-connectors path').length, nodes: [...s.querySelectorAll('.node[data-parent-id]')].filter(n => n.dataset.parentId).length };
});
check(clean.trees === 7 && clean.l2r === 7 && clean.vertical === 0 && clean.svgs === 7 && clean.paths === clean.nodes, 'LR reference: TB/Hybrid/LR return has one fresh engine and SVG per company, no stale vertical lists', clean);
await page.evaluate(() => window.handle.zoomTo(1)); await page.locator('.hm-orgchart-employee').first().focus(); await settle(); await screenshot('lr-card-upright');
measurements.push({ name: 'LR reference upright and refreshed paths', value: await measure() });
}
if (run('performance')) {
for (const options of [{ type: 'lr' }, { type: 'hybrid', hybridStartDepth: 2 }, { type: 'hybrid', hybridStartDepth: 3 }, { type: 'hybrid', hybridStartDepth: 4 }]) {
const started = Date.now(); await mount(template, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: options }); await ready(300);
const readyMs = Date.now() - started, mountMs = await page.evaluate(() => window.mountMs);
const toggle = page.locator('.node[data-organization-id="hanmac-division-0"] > .hm-orgchart-organization .hm-orgchart-expanded-toggle').first();
const collapseStarted = Date.now(); await toggle.press('Enter'); await ready(); const collapseMs = Date.now() - collapseStarted;
await toggle.press('Enter'); await ready(300);
const zoomStarted = Date.now(); await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); const zoomMs = Date.now() - zoomStarted;
const fitStarted = Date.now(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const fitMs = Date.now() - fitStarted;
const value = await measure();
measurements.push({ name: '300 performance ' + JSON.stringify(options), mountMs, fullReadyMs: readyMs, collapseMs, zoomMs, fitMs, value });
check(value.cards.length === 300 && fit(value), '300 performance sample completes at full data: ' + JSON.stringify(options), { mountMs, fullReadyMs: readyMs, collapseMs, zoomMs, fitMs, heap: value.heap, domCount: value.domCount });
}
}
if (run('dialog-narrow')) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(140);
await page.locator('.hm-orgchart-employee').first().press('Enter'); const d = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await d.waitFor(); await settle();
const bounds = await d.evaluate(d => {
const r = d.getBoundingClientRect();
return { x: r.left, y: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height, scrollWidth: d.scrollWidth, clientWidth: d.clientWidth, fields: [...d.querySelectorAll('dd')].map(e => ({ text: e.textContent, scroll: e.scrollWidth, client: e.clientWidth })), active: d.contains(document.activeElement) };
});
check(bounds.x >= 0 && bounds.y >= 0 && bounds.right <= 390 && bounds.bottom <= 844 && bounds.scrollWidth <= bounds.clientWidth && bounds.fields.every(f => f.scroll <= f.client) && bounds.active, 'HM Dialog 390px: content, fields, focus fit viewport without horizontal overflow', bounds);
await screenshot('hm-dialog-390'); await page.keyboard.press('Escape'); await d.waitFor({ state: 'hidden' });
}
check(errors.length === 0, 'No new console or runtime errors', errors);
} catch (error) {
check(false, 'Verification runner completed', { message: error.message, stack: error.stack });
} finally {
const pass = results.filter(r => r.status === 'PASS').length;
const fail = results.filter(r => r.status === 'FAIL').length;
await fs.writeFile(path.join(output, only.length ? `browser-${only.join('-')}.json` : 'browser.json'), JSON.stringify({ pass, fail, results, errors, measurements, chrome: await browser.version() }, null, 2));
console.log(JSON.stringify({ pass, fail, errors: errors.length }));
await browser.close(); await new Promise(resolve => server.close(resolve));
process.exitCode = fail ? 1 : 0;
}