Files
HMDesign/examples/org-chart/verify-p7.mjs
T

546 lines
52 KiB
JavaScript
Raw 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 select(label, option) {
await page.getByRole('combobox', { name: label, exact: true }).click();
await page.getByRole('option', { name: option, exact: true }).click();
await settle();
}
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')) {
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(20);
check(await page.locator('.hm-orgchart-story-companies').first().getByRole('checkbox').count() === 7 && new Set((await measure()).nodes.map(n => n.company)).size === 7, 'Story total20: all seven company controls and rendered trees');
for (const count of [100, 300]) {
await select('전체 직원 수', `${count}명`);
check(await page.locator('.hm-orgchart-employee').count() !== count, `Story total${count}: draft choice does not render before Apply`);
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(count);
check((await measure()).cards.length === count, `Story total${count}: actual selector applies count`);
}
await page.getByRole('button', { name: '선택 해제', exact: true }).click();
await page.getByRole('checkbox', { name: '한맥기술', exact: true }).check();
await select('데이터 범위', '회사별');
for (const count of [20, 100, 300]) {
await select('회사당 직원 수', `${count}명`);
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(count);
check((await measure()).cards.every(c => c.company === 'hanmac'), `Story per-company${count}: exact chosen roster`);
}
await page.getByRole('checkbox', { name: '삼안', exact: true }).check();
await select('회사당 직원 수', '100명');
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(200);
check(new Set((await measure()).cards.map(c => c.company)).size === 2, 'Story two companies100: actual 200');
for (const [label, type] of [['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid'], ['세로형 TB', 'tb']]) {
await select('조직도 표시 유형', label); await ready(200);
check((await measure()).layout === type, `Story layout ${type}: real screen switched`);
await screenshot('story-' + type + '-200');
}
check(await page.getByRole('combobox', { name: '혼합형 시작 Depth', exact: true }).isDisabled(), 'Story Hybrid depth limited outside Hybrid');
await select('조직도 표시 유형', '혼합형 Hybrid');
for (const depth of [2, 3, 4]) { await select('혼합형 시작 Depth', `Depth ${depth}부터 세로 배치`); await ready(200); check(!overlap((await measure()).cards.map(c => c.box)), `Story HybridDepth${depth}: actual control applied safely`); }
await hmPopup('Story HM');
// Source/company disappearance closes the external popup safely.
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor();
await page.evaluate(() => {
const overlay = document.querySelector('[data-component="HMOrgChartEmployeeDialog"]');
// Escape to dismiss first; normal UI then commits an empty company view.
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
});
await page.keyboard.press('Escape');
await page.getByRole('button', { name: '선택 해제', exact: true }).click();
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(0);
check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').count() === 0 && await page.locator('.hm-orgchart-expanded-empty').count() === 1, 'Story empty selection safely clears view/popup');
await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20);
await select('조직도 표시 유형', '세로형 TB');
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(); const collapsed = await page.locator('.hm-orgchart-employee').count();
await select('직원 정보 팝업', 'Core 기본 팝업'); await ready();
check(await page.locator('.hm-orgchart-employee').count() === collapsed, 'Story popup ownership switch retains independent collapse');
await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('dialog[open]').waitFor(); await page.keyboard.press('Escape');
await select('직원 정보 팝업', '사용 안 함'); await ready();
await page.locator('.hm-orgchart-employee').first().press('Enter'); await settle();
check(await page.getByRole('dialog').count() === 0, 'Story popup none: no default or HM popup');
await select('직원 정보 팝업', 'HMDesign Dialog'); await ready();
await toggle.focus(); await toggle.press('Enter'); await ready(20);
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);
}
await select('조직도 표시 유형', '가로형 LR');
await select('초기 표시 Depth', '2Depth까지');
await page.getByRole('button', { name: '전체 회사', exact: true }).click();
await select('데이터 범위', '회사별'); await select('회사당 직원 수', '300명');
check(await page.getByText(/대용량 2,100명 전환 예정/).count() === 1, 'Story2100: scale disclosed before data commit');
await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready();
check(await page.getByText(/실제 직원 합계 2,100명/).count() === 1 && (await measure()).nodes.every(n => n.depth < 2), 'Story2100: source count retained with independent initial-depth reduction');
await screenshot('story-2100-depth2');
await page.getByRole('button', { name: '초기화', exact: true }).click();
await select('초기 표시 Depth', '전체 펼침'); await select('조직도 표시 유형', '세로형 TB'); await ready(20);
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);
// Actual new React/Story settings and changed content layout, without replaying all data 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(20);
for (const [label, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) {
await select('조직도 표시 유형', label); await ready(20);
await page.getByRole('checkbox', { name: '접기·펼치기 사용', exact: true }).uncheck();
await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).uncheck();
await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).uncheck(); await ready(20);
check(await page.locator('.hm-orgchart-expanded-toggle').first().isDisabled() && await page.getByRole('button', { name: '확대', exact: true }).isDisabled() && await page.locator('.hm-orgchart-viewport').getAttribute('data-pan-enabled') === 'false', `Story ${type}: all independent checkboxes control actual Core`);
await hmPopup('Final ' + type);
await page.getByRole('checkbox', { name: '접기·펼치기 사용', exact: true }).check();
await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).check();
await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).check(); await ready(20);
}
await select('직원 정보 팝업', '사용 안 함'); await ready(20);
check(await page.locator('.hm-orgchart-employee[role="button"]').count() === 0 && await page.locator('.hm-orgchart-employee[tabindex="0"]').count() === 0, 'Story popup none: no false actionable card semantics');
await select('직원 정보 팝업', 'HMDesign Dialog'); await ready(20);
}
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(20);
}
await select('조직도 표시 유형', '세로형 TB');
await select('특정 그룹 세로 배치', '한맥기술 · 가상 조직'); await ready(20);
check(await page.locator('[data-group-hybrid="true"]').count() === 1, 'Story per-group Hybrid: real applied local branch');
await select('조직도 표시 유형', '가로형 LR'); await ready(20);
check(await page.getByRole('combobox', { name: '특정 그룹 세로 배치', exact: true }).isDisabled(), 'Story per-group Hybrid: incompatible LR selection disabled and cleared');
await select('조직도 표시 유형', '세로형 TB');
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(20);
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;
}