// 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('
'); 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; }