import fs from 'node:fs/promises'; import assert from 'node:assert/strict'; import path from 'node:path'; import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; const output = path.resolve('test-results/org-chart-p11'); await fs.mkdir(output, { recursive: true }); const browser = await launchChromium({ headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); const errors = [], checks = [], measurements = {}; page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); const baseline = process.argv.includes('--baseline'); const origin = process.env.ORGCHART_P11_ORIGIN ?? 'http://127.0.0.1:6026'; async function open(runtime, mode, theme = false, extra = '') { await page.goto(`${origin}/p11.html?runtime=${runtime}&mode=${mode}${theme ? '&theme=1' : ''}${extra}`); await page.locator('.hm-orgchart-employee').first().waitFor(); await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('link')].every(link => link.sheet)); await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => setTimeout(resolve, 600)); }); } async function styles() { return page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; const selectors = '.hm-orgchart-shell,.hm-orgchart-viewport,.hm-orgchart-organization-name,.hm-orgchart-employee,.hm-orgchart-status-group > .hm-orgchart-summary-title,.hm-orgchart-employee-dialog,.hm-orgchart-employee-dialog-close'; const fields = ['backgroundColor', 'backgroundImage', 'color', 'borderTopColor', 'borderRightColor', 'borderTopWidth', 'boxShadow', 'width', 'height', 'fontSize', 'padding', 'borderRadius']; return [...shadow.querySelectorAll(selectors)].map(el => ({ tag: el.tagName, class: el.className, company: el.dataset.companyId ?? el.closest('[data-company-id]')?.dataset.companyId, kind: el.dataset.cardKind, status: el.dataset.workStatus, css: Object.fromEntries(fields.map(field => [field, getComputedStyle(el)[field]])) })); }); } async function check(name, run) { await run(); checks.push({ name, status: 'PASS' }); } try { for (const runtime of ['vanilla', 'react']) for (const mode of ['auto', 'fixed', 'interactive']) { const key = `${runtime}-${mode}`; await open(runtime, mode); measurements[key] = await styles(); await page.screenshot({ path: path.join(output, `${baseline ? 'before' : 'after'}-${key}.png`) }); if (!baseline) { const before = JSON.parse(await fs.readFile(path.join(output, 'baseline.json'), 'utf8')); await check(`${key}: default colors and geometry identical`, () => assert.equal(JSON.stringify(measurements[key]), JSON.stringify(before[key]))); } // Popup is captured separately so it cannot obscure the chart screenshot. await page.locator('.hm-orgchart-employee').first().click(); await page.locator('.hm-orgchart-employee-dialog[open]').waitFor(); measurements[`${key}-popup`] = (await styles()).filter(row => row.class.includes('dialog')); if (!baseline) { const before = JSON.parse(await fs.readFile(path.join(output, 'baseline.json'), 'utf8')); await check(`${key}: default popup identical`, () => assert.equal(JSON.stringify(measurements[`${key}-popup`]), JSON.stringify(before[`${key}-popup`]))); } } if (baseline) await fs.writeFile(path.join(output, 'baseline.json'), JSON.stringify(measurements, null, 2)); else { const expectedCompanies = { hanmac: 'rgb(36, 87, 167)', saman: 'rgb(56, 92, 168)', 'jangheon-industry': 'rgb(20, 120, 107)' }; const value = (selector, property, pseudo = null) => page.locator(selector).first().evaluate((el, { property, pseudo }) => getComputedStyle(el, pseudo)[property], { property, pseudo }); for (const runtime of ['vanilla', 'react']) { await open(runtime, 'auto', true); for (const [id, color] of Object.entries(expectedCompanies)) { await check(`${runtime}: external CSS ${id} title`, async () => assert.equal(await value(`.node[data-company-id="${id}"] .hm-orgchart-organization-name`, 'backgroundColor'), color)); await check(`${runtime}: external CSS ${id} card accent`, async () => assert.equal(await value(`.hm-orgchart-employee[data-company-id="${id}"]`, 'borderTopColor'), color)); } await check(`${runtime}: canvas and title text`, async () => { assert.equal(await value('.hm-orgchart-viewport', 'backgroundColor'), 'rgb(237, 244, 255)'); assert.equal(await value('.hm-orgchart-organization-name', 'color'), 'rgb(255, 244, 214)'); }); await check(`${runtime}: independent card roles and border`, async () => { assert.equal(await value('.hm-orgchart-employee[data-card-kind="employee"]', 'backgroundColor'), 'rgb(244, 248, 255)'); assert.equal(await value('.hm-orgchart-employee[data-card-kind="topLeader"]', 'backgroundColor'), 'rgb(210, 227, 255)'); assert.equal(await value('.hm-orgchart-employee', 'borderRightColor'), 'rgb(82, 122, 169)'); }); await check(`${runtime}: employee and organization connector theme`, async () => { assert.equal(await value('.hm-orgchart-employee-connectors path', 'stroke'), 'rgb(36, 154, 121)'); assert.equal(await value('.hm-orgchart-engine > ul > li > ul li > .node', 'backgroundColor', '::before'), 'rgb(36, 154, 121)'); }); const employee = page.locator('.hm-orgchart-employee[data-card-kind="employee"]').first(); await employee.hover(); await check(`${runtime}: actual card hover`, async () => assert.equal(await value('.hm-orgchart-employee[data-card-kind="employee"]', 'backgroundColor'), 'rgb(193, 216, 248)')); await page.mouse.move(0, 0); await employee.focus(); await check(`${runtime}: actual card focus`, async () => assert.equal(await value('.hm-orgchart-employee[data-card-kind="employee"]', 'outlineColor'), 'rgb(118, 80, 165)')); await employee.press('Enter'); await page.locator('.hm-orgchart-employee-dialog[open]').waitFor(); await check(`${runtime}: Core popup background, text and border`, async () => { assert.equal(await value('.hm-orgchart-employee-dialog', 'backgroundColor'), 'rgb(238, 242, 255)'); assert.equal(await value('.hm-orgchart-employee-dialog', 'color'), 'rgb(40, 53, 104)'); assert.equal(await value('.hm-orgchart-employee-dialog', 'borderTopColor'), 'rgb(104, 121, 186)'); }); const close = page.locator('.hm-orgchart-employee-dialog-close'); await close.hover(); await page.waitForTimeout(200); await check(`${runtime}: popup hover`, async () => assert.equal(await value('.hm-orgchart-employee-dialog-close', 'backgroundColor'), 'rgb(220, 226, 249)')); await page.mouse.move(0, 0); await close.focus(); await page.waitForTimeout(200); await check(`${runtime}: popup focus`, async () => assert.equal(await value('.hm-orgchart-employee-dialog-close', 'borderTopColor'), 'rgb(137, 88, 186)')); await page.keyboard.press('Escape'); await check(`${runtime}: popup Escape and focus restoration`, async () => assert.equal(await employee.evaluate(el => el.getRootNode().activeElement === el), true)); await page.screenshot({ path: path.join(output, `custom-${runtime}-auto.png`) }); await open(runtime, 'fixed', true); await check(`${runtime}: Fixed headers and background`, async () => { assert.match(await value('[data-group-variant="0"] .hm-orgchart-organization-name', 'backgroundImage'), /rgb\(36, 87, 167\)/); assert.equal(await value('.hm-orgchart-composition', 'backgroundColor'), 'rgb(220, 232, 248)'); assert.equal(await value('.hm-orgchart-workdesk-overlay path[data-connector]', 'stroke'), 'rgb(41, 122, 176)'); }); for (const [status, color] of [['dispatched', 'rgb(39, 136, 87)'], ['onLeave', 'rgb(128, 80, 161)']]) { await check(`${runtime}: ${status} external gradient and border`, async () => { const selector = `.hm-orgchart-status-group[data-work-status="${status}"] .hm-orgchart-employee`; assert.equal(await value(selector, 'borderTopColor'), color); assert.notEqual(await value(selector, 'backgroundImage'), 'none'); }); } await page.screenshot({ path: path.join(output, `custom-${runtime}-fixed.png`) }); await open(runtime, 'auto'); const unthemed = await styles(); await page.evaluate(() => { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = window.p11HostThemeUrl; link.id = 'host-theme'; document.head.append(link); document.querySelector('#root').classList.add('orgchart-custom-theme'); }); await page.waitForFunction(() => document.querySelector('#host-theme').sheet); await check(`${runtime}: host CSS crosses Shadow boundary without inline conflict`, async () => { assert.equal(await value('.node[data-company-id="hanmac"] .hm-orgchart-organization-name', 'backgroundColor'), expectedCompanies.hanmac); assert.equal(await value('.node[data-company-id="saman"] .hm-orgchart-organization-name', 'backgroundColor'), expectedCompanies.saman); assert.equal(await value('.hm-orgchart-viewport', 'backgroundColor'), 'rgb(237, 244, 255)'); }); await page.evaluate(() => document.querySelector('#root').classList.remove('orgchart-custom-theme')); await check(`${runtime}: removing host theme restores defaults immediately`, async () => assert.equal(JSON.stringify(await styles()), JSON.stringify(unthemed))); await open(runtime, 'auto', false, '&all=1'); await check(`${runtime}: all seven approved default company colors`, async () => { for (const [id, color] of Object.entries({ 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)' })) assert.equal(await value(`.node[data-company-id="${id}"] .hm-orgchart-organization-name`, 'backgroundColor'), color); }); await open(runtime, 'auto', true, '&all=1&statuses=1&drag=1'); for (const [id, color] of Object.entries({ ...expectedCompanies, jangheon: 'rgb(80, 58, 145)', ptc: 'rgb(117, 80, 36)', halla: 'rgb(32, 120, 121)', baron: 'rgb(77, 98, 149)' })) { await check(`${runtime}: seven-company theme ${id}`, async () => assert.equal(await value(`.node[data-company-id="${id}"] .hm-orgchart-organization-name`, 'backgroundColor'), color)); } await check(`${runtime}: Auto status cards use external gradients and borders`, async () => { assert.equal(await value('.hm-orgchart-employee[data-work-status="dispatched"]', 'borderRightColor'), 'rgb(39, 136, 87)'); assert.match(await value('.hm-orgchart-employee[data-work-status="onLeave"]', 'backgroundImage'), /rgb\(214, 187, 235\)/); }); await check(`${runtime}: group leader has an independent background`, async () => assert.equal(await value('.hm-orgchart-employee[data-card-kind="leader"]', 'backgroundColor'), 'rgb(227, 238, 255)')); await page.evaluate(() => { const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; const removeHover = rules => { for (const rule of rules) { rule.style?.removeProperty('--hm-orgchart-card-hover-background'); if (rule.styleSheet) removeHover(rule.styleSheet.cssRules); if (rule.cssRules) removeHover(rule.cssRules); } }; for (const link of shadow.querySelectorAll('link')) removeHover(link.sheet.cssRules); }); const statusCard = page.locator('.hm-orgchart-employee[data-work-status="dispatched"]').first(); const statusGradient = await value('.hm-orgchart-employee[data-work-status="dispatched"]', 'backgroundImage'); await statusCard.hover(); await check(`${runtime}: unspecified hover preserves status gradient`, async () => assert.equal(await value('.hm-orgchart-employee[data-work-status="dispatched"]', 'backgroundImage'), statusGradient)); await check(`${runtime}: Drag & Drop ON`, async () => assert.equal(await page.locator('.hm-orgchart-viewport').getAttribute('data-drag-drop-enabled'), 'true')); await open(runtime, 'auto', true); await check(`${runtime}: Drag & Drop OFF`, async () => assert.equal(await page.locator('.hm-orgchart-viewport').getAttribute('data-drag-drop-enabled'), 'false')); await open(runtime, 'auto', true, '&groups=1'); await check(`${runtime}: independent group background via external CSS file`, async () => assert.equal(await value('.hm-orgchart-organization-name', 'backgroundColor'), 'rgb(97, 62, 139)')); await open(runtime, 'auto', true); const forest = page.locator('.hm-orgchart-forest'); const transform = await forest.evaluate(el => el.style.transform); const viewport = await page.locator('.hm-orgchart-viewport').boundingBox(); const mouseX = viewport.x + viewport.width - 12, mouseY = viewport.y + viewport.height - 12; await page.mouse.move(mouseX, mouseY); await page.mouse.wheel(0, -150); await page.waitForTimeout(150); await check(`${runtime}: direct wheel zoom`, async () => assert.notEqual(await forest.evaluate(el => el.style.transform), transform)); const zoomed = await forest.evaluate(el => el.style.transform); await page.mouse.move(mouseX, mouseY); await page.mouse.down(); await page.mouse.move(mouseX - 50, mouseY - 50, { steps: 5 }); await page.mouse.up(); await check(`${runtime}: pan`, async () => assert.notEqual(await forest.evaluate(el => el.style.transform), zoomed)); await open(runtime, 'auto', true); const collapse = page.locator('.hm-orgchart-expanded-toggle').first(); await collapse.click(); await check(`${runtime}: collapse`, async () => assert.equal(await page.locator('.hm-orgchart-expanded-toggle').first().getAttribute('aria-expanded'), 'false')); await page.locator('.hm-orgchart-expanded-toggle').first().click(); await check(`${runtime}: expand`, async () => assert.equal(await page.locator('.hm-orgchart-expanded-toggle').first().getAttribute('aria-expanded'), 'true')); for (const width of [390, 768, 1280, 1440]) { await page.setViewportSize({ width, height: 900 }); await open(runtime, 'auto', true); await check(`${runtime}: ${width}px canvas fits and document does not overflow`, async () => { const box = await page.locator('.hm-orgchart-viewport').boundingBox(); assert.equal(box.width, width); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); }); await open(runtime, 'fixed', true); await check(`${runtime}: ${width}px Fixed document does not overflow`, async () => assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)); } } } assert.deepEqual(errors, [], 'No browser errors'); } finally { await fs.writeFile(path.join(output, baseline ? 'baseline-result.json' : 'browser-result.json'), JSON.stringify({ checks, errors, measurements }, null, 2)); await browser.close(); } console.log(`${baseline ? 'Baseline' : 'P11'}: ${checks.length} checks, ${errors.length} errors`);