// Actual Expanded Storybook UI: seven companies x three sizes x three layouts. // Build Storybook first; uses the existing optional Playwright/Chrome runtime. import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import http from 'node:http'; import path from 'node:path'; import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; const output = path.resolve('test-results/org-chart-hierarchy'); const directory = path.join(output, 'storybook'); const template = JSON.parse(await fs.readFile('examples/org-chart/data-expanded-large.json', 'utf8')); const companies = template.affiliations; const mime = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }; 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(); const file = path.resolve(directory, '.' + (pathname === '/' ? '/index.html' : pathname)); const relative = path.relative(directory, 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)); let browser, failure; // Resume only complete company groups after a test harness interruption. const savedChecks = process.argv.includes('--resume') ? JSON.parse(await fs.readFile(path.join(output, 'browser-progress.json'), 'utf8')).checks : []; const checks = savedChecks.filter(check => savedChecks.filter(row => row.company === check.company).length === 9); const errors = []; try { browser = await launchChromium({ headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); const ready = async count => { await page.waitForFunction(count => { const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; return shadow?.querySelector('.hm-orgchart-shell[aria-busy="false"]') && (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count) && [...shadow.querySelectorAll('link')].every(link => link.sheet); }, count); await settle(); }; const select = async (label, option) => { await page.getByRole('combobox', { name: label, exact: true }).click(); await page.getByRole('option', { name: option, exact: true }).click(); }; const snapshot = () => page.locator('.hm-orgchart-host').evaluate(host => { const shadow = host.shadowRoot; const box = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; }; return { nodes: [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => ({ id: node.dataset.organizationId, parentId: node.dataset.parentId || null, type: node.dataset.organizationType })), employees: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, organizationId: card.closest('.node').dataset.organizationId })), viewport: box(shadow.querySelector('.hm-orgchart-viewport')), forest: box(shadow.querySelector('.hm-orgchart-forest')), contentBounds: (() => { const boxes = [...shadow.querySelectorAll('.hm-orgchart-organization, .hm-orgchart-organization-connectors path')].filter(element => element.getClientRects().length).map(box); const x = Math.min(...boxes.map(b => b.x)), y = Math.min(...boxes.map(b => b.y)); return { x, y, width: Math.max(...boxes.map(b => b.x + b.width)) - x, height: Math.max(...boxes.map(b => b.y + b.height)) - y }; })(), layout: shadow.querySelector('.hm-orgchart-expanded').dataset.autoLayout, paths: [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(p => ({ id: p.dataset.organizationId, parentId: p.dataset.parentId, d: p.getAttribute('d') })), upstreamConnectors: [...shadow.querySelectorAll('.hierarchy, .node[data-depth]')].flatMap(element => ['::before', '::after'].map(pseudo => { const style = getComputedStyle(element, pseudo); return style.content !== 'none' && style.display !== 'none' && [style.borderTopWidth, style.borderLeftWidth, style.borderRightWidth, style.borderBottomWidth].some(width => parseFloat(width) > 0); })).filter(Boolean).length, }; }); const fit = async () => { await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const { viewport: v, contentBounds: f } = await snapshot(); assert.ok(f.x >= v.x - 1 && f.y >= v.y - 1 && f.x + f.width <= v.x + v.width + 1 && f.y + f.height <= v.y + v.height + 1, `complete forest fits viewport: ${JSON.stringify({ viewport: v, forest: f })}`); }; await page.goto(`http://127.0.0.1:${server.address().port}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await ready(140); let previousCompany; for (const company of companies) { if (checks.filter(check => check.company === company.id).length === 9) continue; await page.getByRole('button', { name: /회사 선택/ }).click(); await page.getByRole('checkbox', { name: previousCompany?.name ?? '전체 선택', exact: true }).uncheck(); await page.getByRole('checkbox', { name: company.name, exact: true }).check(); await page.keyboard.press('Escape'); previousCompany = company; for (const count of [20, 100, 300]) { await select('회사당 직원 수', `${count}명`); await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(count); const expected = template.organizations.filter(org => org.companyId === company.id).map(org => ({ id: org.id, parentId: org.parentId, type: org.type })).sort((a, b) => a.id.localeCompare(b.id)); let employees; for (const [type, label] of [['tb', '세로형 TB'], ['lr', '가로형 LR'], ['hybrid', '혼합형 Hybrid']]) { await select('조직도 표시 유형', label); await ready(count); const rendered = await snapshot(); assert.equal(rendered.layout, type); assert.deepEqual(rendered.nodes.sort((a, b) => a.id.localeCompare(b.id)), expected); const byId = new Map(rendered.nodes.map(org => [org.id, org])); for (const team of rendered.nodes.filter(org => org.type === 'team')) { let ancestor = byId.get(team.parentId); while (ancestor) { assert.notEqual(ancestor.type, 'cell'); ancestor = byId.get(ancestor.parentId); } } const roster = rendered.employees.sort((a, b) => a.id.localeCompare(b.id)); assert.equal(new Set(roster.map(employee => employee.id)).size, count); assert.ok(roster.every(employee => byId.has(employee.organizationId))); if (employees) assert.deepEqual(roster, employees); else employees = roster; if (type === 'tb') assert.ok(rendered.upstreamConnectors > 0, 'TB engine CSS connectors are present'); else { assert.equal(rendered.paths.length, expected.filter(org => org.parentId).length); assert.ok(rendered.paths.every(p => p.d && !/NaN|Infinity/.test(p.d) && byId.get(p.id)?.parentId === p.parentId), 'measured connectors follow parent IDs and have finite geometry'); } await fit(); const teamId = `${company.id}-team-0`, cellId = `${company.id}-cell-0`; const toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${teamId}"]`); await toggle.focus(); await toggle.press('Enter'); await ready(); assert.equal(await page.locator(`.node[data-organization-id="${cellId}"]`).count(), 0); assert.ok((await snapshot()).employees.length < count); await fit(); await toggle.focus(); await toggle.press('Enter'); await ready(count); assert.equal(await page.locator(`.node[data-organization-id="${cellId}"]`).count(), 1); assert.deepEqual((await snapshot()).employees.sort((a, b) => a.id.localeCompare(b.id)), employees); await fit(); checks.push({ company: company.id, employeeCount: count, layout: type, hierarchy: 'PASS', employeeIdentity: 'PASS', connectors: 'PASS', collapseExpand: 'PASS', fit: 'PASS' }); await fs.writeFile(path.join(output, 'browser-progress.json'), JSON.stringify({ checks, errors }, null, 2)); if (company.id === 'hanmac' && count === 300) await page.screenshot({ path: path.join(output, `storybook-${type}-300.png`), fullPage: true }); } } console.log(`${company.name}: 9 combinations PASS`); } assert.deepEqual(errors, []); assert.equal(await page.getByRole('alert').count(), 0); assert.equal(checks.length, companies.length * 9); assert.equal(new Set(checks.map(check => `${check.company}/${check.employeeCount}/${check.layout}`)).size, companies.length * 9); } catch (error) { failure = error.stack; throw error; } finally { await fs.writeFile(path.join(output, 'browser-result.json'), JSON.stringify({ status: failure ? 'FAIL' : 'PASS', checks, errors, ...(failure ? { failure } : {}) }, null, 2)); await browser?.close(); await new Promise(resolve => server.close(resolve)); } console.log(`Expanded Storybook: ${checks.length}/63 combinations PASS; ${errors.length} browser errors`);