import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; import http from 'node:http'; import path from 'node:path'; import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; // P5-1B focused integration suite. Preserve historical verification evidence. // Copy the pre-change public Core dist to baseline-dist before rebuilding. const root = fileURLToPath(new URL('../../', import.meta.url)); const output = path.join(root, 'test-results/org-chart-p5-1b'); const storyOrigin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6053'; await loadPlaywright(); const fixedFixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); const expandedFixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded.json'), 'utf8')); const presets = [ { id: 'hanmac', name: '한맥기술', primary: '#CB2E28', secondary: ['#1D4289'] }, { id: 'saman', name: '삼안', primary: '#F08300', secondary: ['#6D6C6C'] }, { id: 'jangheon-industry', name: '장헌산업', primary: '#4580A6', secondary: ['#92BED4'] }, { id: 'jangheon', name: '장헌', primary: '#1D418D', secondary: ['#AE232A'] }, { id: 'ptc', name: '피티씨', primary: '#84338E', secondary: ['#F7AF00', '#0066B5'] }, { id: 'halla', name: '한라산업개발', primary: '#0068B7', secondary: ['#231815'] }, { id: 'baron', name: '바론컨설턴트', primary: '#0C367F', secondary: [] }, ]; const results = [], measurements = [], errors = [], warnings = []; const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); const near = (a, b) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= 0.35; const rgb = hex => `rgb(${[1, 3, 5].map(offset => parseInt(hex.slice(offset, offset + 2), 16)).join(', ')})`; const expectedCompanyIds = presets.map(preset => preset.id); const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml', '.png': 'image/png' }; const shellHtml = '
'; await fs.mkdir(output, { recursive: true }); const server = http.createServer(async (request, response) => { try { const url = new URL(request.url, 'http://127.0.0.1'); if (url.pathname === '/__p51b') return response.writeHead(200, { 'Content-Type': 'text/html' }).end(shellHtml); const pathname = decodeURIComponent(url.pathname); const file = path.resolve(root, '.' + (pathname.startsWith('/fonts/') ? '/public' + pathname : pathname)); const relative = path.relative(root, file); if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); const bytes = await fs.readFile(file); response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }); response.end(bytes); } 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 }); async function newPage(name, viewport = { width: 1440, height: 900 }) { const page = await browser.newPage({ viewport }); page.on('pageerror', error => errors.push({ name, type: 'pageerror', message: error.message })); page.on('console', message => { if (message.type() === 'error') errors.push({ name, type: 'console', message: message.text() }); if (message.type() === 'warning') warnings.push({ name, message: message.text() }); }); return page; } async function ready(page, mode) { await page.waitForFunction(expected => { const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; const content = shadow?.querySelector(expected === 'auto' ? '.hm-orgchart-expanded' : '.hm-orgchart-composition'); return content?.querySelector('.hm-orgchart-employee') && [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => link.sheet); }, mode, { timeout: 25000 }); await page.evaluate(async () => { await document.fonts.load('600 24px "Noto Sans KR"', '홍 길 동'); await document.fonts.ready; const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; const images = [...shadow.querySelectorAll('.hm-orgchart-employee img')]; for (const image of images) image.loading = 'eager'; await Promise.all(images.map(image => image.decode().catch(() => {}))); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); } async function mount(page, { baseline = false, fixture = expandedFixture, options = {} } = {}) { await page.goto(`${origin}/__p51b`); await page.evaluate(async ({ base, fixture, options }) => { window.hmModule = await import(`${base}/index.js`); window.fixture = structuredClone(fixture); window.handle = window.hmModule.createOrgChart({ container: document.getElementById('chart'), dataSource: new window.hmModule.JsonOrgChartDataSource(fixture), stylesheetUrls: { engine: `${base}/engine.css`, core: `${base}/style.css` }, ...options, }); await window.handle.mount(); }, { base: `${origin}/${baseline ? 'test-results/org-chart-p5-1b/baseline-dist' : 'packages/org-chart/dist'}`, fixture, options }); await ready(page, options.layoutMode === 'auto' ? 'auto' : 'fixed'); return page.evaluate(() => window.handle.getState()); } async function measure(page) { return page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; const stage = shadow.querySelector('.hm-orgchart-stage'); const base = stage?.getBoundingClientRect() ?? shadow.querySelector('.hm-orgchart-expanded').getBoundingClientRect(); const scale = stage ? base.width / stage.offsetWidth : 1; const box = element => { const bounds = element.getBoundingClientRect(); return { x: (bounds.x - base.x) / scale, y: (bounds.y - base.y) / scale, width: bounds.width / scale, height: bounds.height / scale }; }; const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ key: `${card.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? 'tree'}:${card.dataset.employeeId}`, id: card.dataset.employeeId, kind: card.dataset.cardKind, size: card.dataset.cardAssetSize ?? null, box: box(card), asset: card.querySelector('.hm-orgchart-card-background')?.src ?? null, logo: card.querySelector('.hm-orgchart-affiliation-mark img')?.src ?? null, name: card.querySelector('.hm-orgchart-person-name')?.textContent, role: card.querySelector('.hm-orgchart-person-role')?.textContent, font: getComputedStyle(card.querySelector('.hm-orgchart-person-name')).fontFamily, badgeCount: card.querySelectorAll('.hm-orgchart-job-title,.hm-orgchart-status-label,.hm-orgchart-person-attributes').length, placeholder: card.dataset.photoState, })); const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => { const heading = node.querySelector('.hm-orgchart-organization-name'); const style = getComputedStyle(heading); return { id: node.dataset.organizationId, companyId: node.dataset.companyId ?? null, depth: Number(node.dataset.organizationDepth), text: heading.textContent, box: box(heading), backgroundColor: style.backgroundColor, backgroundImage: style.backgroundImage, primary: node.style.getPropertyValue('--hm-orgchart-expanded-primary'), secondary: [...node.style].filter(property => property.startsWith('--hm-orgchart-expanded-secondary-')).map(property => node.style.getPropertyValue(property)), }; }); const collisions = []; for (let index = 0; index < cards.length; index++) for (const other of cards.slice(index + 1)) { const a = cards[index].box, b = other.box; if (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) collisions.push([cards[index].key, other.key]); } const viewport = shadow.querySelector('.hm-orgchart-viewport'); const expanded = shadow.querySelector('.hm-orgchart-expanded'); const summaries = [...shadow.querySelectorAll('[data-component="PositionSummary"],[data-component="AffiliationSummary"],[data-component="StatusSummary"]')].map(element => ({ kind: element.dataset.component, text: element.textContent })); return { scale, cards, nodes, summaries, collisions, hostCount: document.querySelectorAll('.hm-orgchart-host').length, compositionCount: shadow.querySelectorAll('.hm-orgchart-composition,.hm-orgchart-expanded').length, controls: shadow.querySelectorAll('.hm-orgchart-toggle,.zoom-controls,.pan-controls,.minimap').length, layoutStatus: expanded?.dataset.layoutStatus ?? null, phaseText: expanded?.textContent ?? '', viewport: viewport ? { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX } : null, document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight }, }; }); } function compareFixed(before, after, name) { check(JSON.stringify(before.cards.map(({ box, ...rest }) => rest)) === JSON.stringify(after.cards.map(({ box, ...rest }) => rest)), `${name}: card assets/content/typography/placeholder unchanged`); check(before.cards.length === after.cards.length && before.cards.every((card, index) => ['x', 'y', 'width', 'height'].every(key => near(card.box[key], after.cards[index].box[key]))), `${name}: fixed card coordinates and dimensions unchanged`); check(before.nodes.length === after.nodes.length && before.nodes.every((node, index) => { const next = after.nodes[index]; return node.id === next.id && node.depth === next.depth && node.text === next.text && node.backgroundColor === next.backgroundColor && node.backgroundImage === next.backgroundImage && ['x', 'y', 'width', 'height'].every(key => near(node.box[key], next.box[key])); }), `${name}: hierarchy headers and Workdesk gradients unchanged`); check(JSON.stringify(before.summaries) === JSON.stringify(after.summaries), `${name}: summary counts unchanged`); check(after.controls === 0 && after.compositionCount === 1 && after.layoutStatus === null, `${name}: fixed read-only renderer retained`); } async function popup(page, name, id) { const card = page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`).first(); await card.focus(); await card.press('Enter'); await page.locator('dialog[open]').waitFor(); const data = await page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; const dialog = shadow.querySelector('dialog[open]'); return { employeeId: dialog.dataset.employeeId, text: dialog.textContent, count: shadow.querySelectorAll('dialog[open]').length, activeInside: dialog.contains(shadow.activeElement) }; }); check(data.employeeId === id && data.count === 1 && data.activeInside, `${name}: keyboard opens shared employee popup`, data); await page.keyboard.press('Tab'); check(await page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; return shadow.querySelector('dialog[open]').contains(shadow.activeElement); }), `${name}: popup Tab focus remains inside`); await page.keyboard.press('Escape'); await page.locator('dialog[open]').waitFor({ state: 'detached' }); check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), `${name}: Escape restores initiating card focus`); return data.text; } async function scenario(name, run) { try { await run(); } catch (error) { check(false, `${name}: verification completed`, { message: error.message, stack: error.stack }); } } async function geometry(page) { return page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; const rect = element => { const r = element.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,cx:r.x+r.width/2}; }; const pseudo = (element, name) => { const s = getComputedStyle(element, name); return {content:s.content,top:s.top,bottom:s.bottom,height:s.height,width:s.width,border:s.borderTopWidth,color:s.backgroundColor}; }; const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => { const hierarchy = node.parentElement; const childRow = [...hierarchy.children].find(item => item.classList.contains('nodes')); const parent = hierarchy.parentElement.closest('.hierarchy')?.querySelector(':scope > .node'); return {id:node.dataset.organizationId,parentId:node.dataset.parentId,domParent:parent?.dataset.organizationId ?? '',depth:Number(node.dataset.depth), node:rect(node),article:rect(node.querySelector('.hm-orgchart-organization')),subtree:rect(hierarchy),children:childRow ? rect(childRow) : null, upper:pseudo(node,'::before'),lower:pseudo(node,'::after'),bar:pseudo(hierarchy,'::before')}; }); return {nodes,tree:rect(shadow.querySelector('.hm-orgchart-forest'))}; }); } async function assertHierarchy(page, name, fixture) { const value=await measure(page), layout=await geometry(page); measurements.push({name,...value,layout}); check(value.nodes.length===fixture.organizations.length,`${name}: every organization rendered`); const expected = fixture.employees.filter(employee => fixture.presentation?.groups.showEmployees !== false || fixture.organizations.some(org=>org.leaderId===employee.id)); check(value.cards.length===expected.length && new Set(value.cards.map(c=>c.id)).size===value.cards.length,`${name}: employees once, no missing or duplicate leaders`); check(value.collisions.length===0,`${name}: card collision count zero`,value.collisions); check(value.controls===0 && value.layoutStatus==='hierarchical' && value.scale===1,`${name}: expanded hierarchy without scale/collapse/zoom/pan controls`); for (const depth of new Set(layout.nodes.map(n=>n.depth))) { const row=layout.nodes.filter(n=>n.depth===depth); check(row.every(n=>near(n.node.y,row[0].node.y)),`${name}: depth ${depth} horizontal alignment`); } for (const n of layout.nodes) { check(n.parentId===n.domParent,`${name}: ${n.id} actual DOM parent matches domain parent`); if(n.parentId) { const parent=layout.nodes.find(p=>p.id===n.parentId); check(parent && n.node.y>parent.article.bottom,`${name}: ${n.id} below its parent content`); check(n.upper.content!=='none' && parseFloat(n.upper.height)>0,`${name}: ${n.id} upward connector exists`); } if(n.children) { check(near(n.node.cx,n.children.cx),`${name}: ${n.id} centered above own subtree`); check(n.lower.content!=='none' && parseFloat(n.lower.height)>0,`${name}: ${n.id} downward connector`); } else check(n.lower.content==='none',`${name}: ${n.id} leaf has no downward connector`); } for (let i=0;i