// Chart Only browser verification against a running preview server: // npx vite --config examples/org-chart-chart-only/vite.config.ts // node examples/org-chart-chart-only/verify.mjs // CHART_ONLY_URL (default http://127.0.0.1:6041) selects the server. import assert from 'node:assert/strict'; import path from 'node:path'; import { mkdir, writeFile } from 'node:fs/promises'; 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-chart-only'); await mkdir(output, { recursive: true }); const base = process.env.CHART_ONLY_URL ?? 'http://127.0.0.1:6041'; const browser = await launchChromium(); const results = []; const consoleErrors = []; const record = (id, name, pass, detail) => { results.push({ id, name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${name}`, detail === undefined ? '' : JSON.stringify(detail)); }; const check = (id, name, fn) => async (...args) => { try { const detail = await fn(...args); record(id, name, true, detail); } catch (error) { record(id, name, false, error.message); } }; /** All geometry in CSS pixels relative to the canvas; bounds include connectors. */ const measure = page => page.evaluate(() => { const sr = document.querySelector('.hm-orgchart-host').shadowRoot; const vp = sr.querySelector('.hm-orgchart-viewport'); const box = vp.getBoundingClientRect(); let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity; for (const e of sr.querySelectorAll('.hm-orgchart-forest .hm-orgchart-organization, .hm-orgchart-forest .hm-orgchart-organization-connectors path')) { const x = e.getBoundingClientRect(); if (!x.width && !x.height) continue; l = Math.min(l, x.left); t = Math.min(t, x.top); r = Math.max(r, x.right); b = Math.max(b, x.bottom); } const css = getComputedStyle; const feedback = sr.querySelector('.hm-orgchart-move-feedback')?.getBoundingClientRect(); return { canvas: { left: box.left, top: box.top, width: box.width, height: box.height }, scale: +vp.dataset.scale, x: +vp.dataset.panX, y: +vp.dataset.panY, pending: vp.dataset.placementPending ?? null, dragDrop: vp.dataset.dragDropEnabled, margins: { l: l - box.left, t: t - box.top, r: box.right - r, b: box.bottom - b }, centerErrX: (l + r) / 2 - (box.left + box.width / 2), centerErrY: (t + b) / 2 - (box.top + box.height / 2), inside: l >= box.left - 0.5 && t >= box.top - 0.5 && r <= box.right + 0.5 && b <= box.bottom + 0.5, cards: sr.querySelectorAll('.hm-orgchart-employee').length, organizations: sr.querySelectorAll('.hm-orgchart-organization').length, connectors: sr.querySelectorAll('.hm-orgchart-forest .edge, .hm-orgchart-organization-connectors path, .hm-orgchart-employee-connectors path').length, external: { heading: !!sr.querySelector('.hm-orgchart-heading'), date: !!sr.querySelector('.hm-orgchart-date'), selection: !!sr.querySelector('.hm-orgchart-expanded-selection'), toolbar: !!sr.querySelector('.hm-orgchart-expanded-toolbar'), zoomRatio: !!sr.querySelector('.hm-orgchart-expanded output'), feedbackVisible: !!feedback && feedback.width > 1 && feedback.height > 1, siblingsOfCanvas: [...sr.querySelector('.hm-orgchart-expanded').children].filter(e => e !== vp && e.getBoundingClientRect().width > 1).map(e => e.className), }, colors: { page: css(document.body).backgroundColor, shell: css(sr.querySelector('.hm-orgchart-shell')).backgroundColor, content: css(sr.querySelector('.hm-orgchart-expanded')).backgroundColor, canvas: css(vp).backgroundColor, canvasBorder: css(vp).borderTopWidth, contentPadding: css(sr.querySelector('.hm-orgchart-expanded')).paddingTop }, companyColors: [...new Set([...sr.querySelectorAll('.node[data-company-id]')].map(n => `${n.dataset.companyId}:${n.style.getPropertyValue('--hm-orgchart-expanded-primary')}`))], pageOverflowX: document.documentElement.scrollWidth > innerWidth, }; }); async function ready(page) { await page.waitForFunction(() => { const sr = document.querySelector('.hm-orgchart-host')?.shadowRoot; const vp = sr?.querySelector('.hm-orgchart-viewport'); return vp && !vp.dataset.placementPending && sr.querySelector('.hm-orgchart-employee') && sr.querySelector('.hm-orgchart-shell')?.getAttribute('aria-busy') === 'false'; }, null, { timeout: 30000 }); // Two settled frames: no later Fit may move the view. await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); } /** Samples the visible transform from navigation until settle; repeated * visible Fits would show up as more than one distinct transform. */ async function visibleTransforms(page, ms = 1500) { return page.evaluate(ms => new Promise(resolve => { const seen = []; const start = performance.now(); const tick = () => { const vp = document.querySelector('.hm-orgchart-host')?.shadowRoot?.querySelector('.hm-orgchart-viewport'); const forest = vp?.querySelector('.hm-orgchart-forest'); if (forest && !vp.dataset.placementPending) { const value = forest.style.transform; if (seen.at(-1) !== value) seen.push(value); } if (performance.now() - start < ms) requestAnimationFrame(tick); else resolve(seen); }; tick(); }), ms); } async function open(context, url) { const page = await context.newPage(); page.on('console', message => { if (message.type() === 'error') consoleErrors.push({ url, text: message.text() }); }); page.on('pageerror', error => consoleErrors.push({ url, text: String(error) })); await page.goto(url); return page; } const near = (a, b, tolerance) => Math.abs(a - b) <= tolerance; const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }); // A/B: output and initial Fit for both consumers and all sizes. for (const kind of ['vanilla', 'react']) { for (const size of ['20', '100', '500']) { const url = `${base}/${kind}.html?data=${size}`; const page = await open(desktop, url); const transforms = await visibleTransforms(page); await ready(page); const m = await measure(page); await page.screenshot({ path: path.join(output, `${kind}-${size}-desktop.png`) }); const label = `${kind} ${size}`; await check('A', `${label} Chart Only output`, () => { assert.deepEqual(Object.entries(m.external).filter(([key, value]) => key === 'siblingsOfCanvas' ? value.length : value), []); assert.equal(m.colors.canvas, 'rgb(255, 255, 255)'); assert.equal(m.colors.content, 'rgb(255, 255, 255)'); assert.equal(m.colors.shell, 'rgb(255, 255, 255)'); assert.equal(m.colors.canvasBorder, '0px'); assert.equal(m.colors.contentPadding, '0px'); assert.ok(m.cards > 0 && m.organizations > 0 && m.connectors > 0); assert.ok(near(m.canvas.width, 1440, 1) && near(m.canvas.height, 900, 1), 'canvas fills window'); return { cards: m.cards, organizations: m.organizations, connectors: m.connectors, colors: m.companyColors }; })(); await check('B', `${label} initial Fit and center`, () => { assert.ok(m.inside, 'bounds inside canvas'); assert.ok(Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, `center error ${m.centerErrX}, ${m.centerErrY}`); assert.ok(m.scale <= 1); assert.ok(Math.min(m.margins.l, m.margins.r, m.margins.t, m.margins.b) >= 15.5, 'edge margin'); assert.equal(transforms.length, 1, `visible transforms ${JSON.stringify(transforms)}`); return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3), margins: Object.fromEntries(Object.entries(m.margins).map(([k, v]) => [k, +v.toFixed(2)])), visibleTransforms: transforms.length }; })(); await page.close(); } } // LR / Hybrid keep their layout and still fit. for (const layout of ['lr', 'hybrid']) { const page = await open(desktop, `${base}/vanilla.html?data=100&layout=${layout}`); await ready(page); const m = await measure(page); await page.screenshot({ path: path.join(output, `vanilla-100-${layout}.png`) }); await check('B', `vanilla 100 ${layout} Fit and center`, () => { assert.ok(m.inside && Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, JSON.stringify(m.margins)); return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3) }; })(); await page.close(); } // C/D: wheel, Pan, popup, groups and replacement on the 500-person chart. for (const kind of ['vanilla', 'react']) { const page = await open(desktop, `${base}/${kind}.html?data=500`); await ready(page); const fitted = await measure(page); const card = page.locator('.hm-orgchart-employee').nth(30); const cardBox = await card.boundingBox(); const point = { x: cardBox.x + cardBox.width / 2, y: cardBox.y + cardBox.height / 2 }; const local = m => ({ x: (point.x - m.canvas.left - m.x) / m.scale, y: (point.y - m.canvas.top - m.y) / m.scale }); await page.mouse.move(point.x, point.y); await page.mouse.wheel(0, -100); await page.waitForTimeout(50); const zoomedIn = await measure(page); await check('C', `${kind} plain wheel up zooms in (no Ctrl/⌘)`, () => { assert.ok(zoomedIn.scale > fitted.scale * 1.1, `${fitted.scale} -> ${zoomedIn.scale}`); return { from: +fitted.scale.toFixed(4), to: +zoomedIn.scale.toFixed(4) }; })(); await check('C', `${kind} zoom anchors at pointer`, () => { const before = local(fitted), after = local(zoomedIn); const error = Math.hypot(before.x - after.x, before.y - after.y) * zoomedIn.scale; const box = { x: cardBox.x, y: cardBox.y }; assert.ok(error < 0.5, `screen drift ${error}`); return { screenDriftPx: +error.toFixed(4), card: box }; })(); await page.mouse.wheel(0, 100); await page.waitForTimeout(50); const zoomedOut = await measure(page); await check('C', `${kind} plain wheel down zooms out`, () => { assert.ok(zoomedOut.scale < zoomedIn.scale); return { from: +zoomedIn.scale.toFixed(4), to: +zoomedOut.scale.toFixed(4) }; })(); for (let i = 0; i < 40; i++) await page.mouse.wheel(0, -120); await page.waitForTimeout(50); const maximum = (await measure(page)).scale; for (let i = 0; i < 80; i++) await page.mouse.wheel(0, 120); await page.waitForTimeout(50); const minimum = (await measure(page)).scale; await check('C', `${kind} min/max limits`, () => { assert.equal(maximum, 3); assert.ok(near(minimum, Math.min(0.05, fitted.scale), 1e-9), `min ${minimum} fit ${fitted.scale}`); return { max: maximum, min: +minimum.toFixed(4), fitScale: +fitted.scale.toFixed(4) }; })(); // Back to a readable zoom around the same card, then Pan from empty space. await page.mouse.move(point.x, point.y); for (let i = 0; i < 6; i++) await page.mouse.wheel(0, -100); await page.waitForTimeout(50); const beforePan = await measure(page); const empty = { x: beforePan.canvas.left + 6, y: beforePan.canvas.top + beforePan.canvas.height - 6 }; const hit = await page.evaluate(({ x, y }) => { const sr = document.querySelector('.hm-orgchart-host').shadowRoot; const e = sr.elementFromPoint(x, y); return e?.closest('.hm-orgchart-employee, button') ? 'control' : e?.className ?? ''; }, empty); await page.mouse.move(empty.x, empty.y); await page.mouse.down(); await page.mouse.move(empty.x + 60, empty.y - 40, { steps: 4 }); await page.mouse.move(empty.x + 120, empty.y - 80, { steps: 4 }); await page.mouse.up(); const afterPan = await measure(page); await check('C', `${kind} Pan after Zoom`, () => { assert.notEqual(hit, 'control'); assert.ok(near(afterPan.x - beforePan.x, 120, 0.5) && near(afterPan.y - beforePan.y, -80, 0.5), `${afterPan.x - beforePan.x}, ${afterPan.y - beforePan.y}`); assert.equal(afterPan.scale, beforePan.scale); return { dx: afterPan.x - beforePan.x, dy: afterPan.y - beforePan.y, scale: +afterPan.scale.toFixed(4) }; })(); await page.waitForTimeout(600); const settled = await measure(page); await check('C', `${kind} no automatic Fit after user Zoom/Pan`, () => { assert.deepEqual([settled.scale, settled.x, settled.y], [afterPan.scale, afterPan.x, afterPan.y]); })(); // Ctrl + wheel also zooms in the canvas and the browser page zoom is not used. await page.keyboard.down('Control'); await page.mouse.move(point.x, point.y); await page.mouse.wheel(0, -100); await page.keyboard.up('Control'); await page.waitForTimeout(50); const ctrlZoom = await measure(page); const pageZoom = await page.evaluate(() => [visualViewport.scale, innerWidth]); await check('C', `${kind} Ctrl + wheel stays inside the canvas`, () => { assert.ok(ctrlZoom.scale > settled.scale); assert.deepEqual(pageZoom, [1, 1440]); return { scale: +ctrlZoom.scale.toFixed(4) }; })(); // D: employee popup keeps the view. const view = await measure(page); const visibleCard = page.locator('.hm-orgchart-employee').filter({ hasNot: page.locator('[hidden]') }).nth(30); await visibleCard.click(); const dialog = page.locator('dialog.hm-orgchart-employee-dialog'); const opened = await dialog.evaluate(element => element.open).catch(() => false); const dialogText = opened ? (await dialog.innerText()).replace(/\s+/g, ' ').slice(0, 80) : ''; await page.keyboard.press('Escape'); await page.waitForTimeout(100); const closed = !(await dialog.evaluate(element => element.open)); const afterDialog = await measure(page); await check('D', `${kind} employee popup open/close keeps Zoom/Pan`, () => { assert.ok(opened, 'dialog opened'); assert.ok(closed, 'dialog closed'); assert.deepEqual([afterDialog.scale, afterDialog.x, afterDialog.y], [view.scale, view.x, view.y]); return { dialogText }; })(); // D: group collapse/expand keeps the view and does not Fit. const toggle = page.locator('.hm-orgchart-expanded-toggle[aria-disabled="false"]').nth(3); const id = await toggle.getAttribute('data-organization-id'); const cardsBefore = afterDialog.cards; await toggle.click(); await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`)?.getAttribute('aria-expanded') === 'false', id); await ready(page); const collapsed = await measure(page); await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`).click(); await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`)?.getAttribute('aria-expanded') === 'true', id); await ready(page); const expanded = await measure(page); await check('D', `${kind} group collapse/expand without auto Fit`, () => { assert.ok(collapsed.cards < cardsBefore && expanded.cards === cardsBefore, `${cardsBefore} -> ${collapsed.cards} -> ${expanded.cards}`); assert.deepEqual([collapsed.scale, expanded.scale], [view.scale, view.scale]); assert.deepEqual([expanded.x, expanded.y], [view.x, view.y]); return { cards: [cardsBefore, collapsed.cards, expanded.cards], scale: +view.scale.toFixed(4) }; })(); // D: Drag & Drop defaults to OFF; a card drag never changes membership. const dragSource = page.locator('.hm-orgchart-employee').nth(40); const sourceId = await dragSource.getAttribute('data-employee-id'); const ownerBefore = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"]`)?.closest('.node')?.dataset.organizationId, sourceId); const sourceBox = await dragSource.boundingBox(); const targetBox = await page.locator('.hm-orgchart-expanded-toggle').nth(1).boundingBox(); await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); await page.mouse.down(); await page.mouse.move(targetBox.x + 10, targetBox.y + 5, { steps: 10 }); await page.mouse.up(); await page.waitForTimeout(200); const afterDrag = await measure(page); const owner = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"]`)?.closest('.node')?.dataset.organizationId, sourceId); await page.keyboard.press('Escape'); await check('D', `${kind} Drag & Drop default OFF`, () => { assert.equal(afterDrag.dragDrop, 'false'); assert.ok(owner && owner === ownerBefore, `${ownerBefore} -> ${owner}`); return { dragDropEnabled: afterDrag.dragDrop, owner }; })(); // B16 / React re-render: user view survives a plain re-render, replacement Fits again. if (kind === 'react') { const userView = await measure(page); await page.evaluate(() => window.rerenderPage()); await page.waitForTimeout(400); const rerendered = await measure(page); await check('C', 'react plain re-render keeps user view (no Fit)', () => { assert.deepEqual([rerendered.scale, rerendered.x, rerendered.y], [userView.scale, userView.x, userView.y]); })(); } await page.evaluate(() => window.replaceData('100')); await page.waitForFunction(() => document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee').length === 100); await ready(page); const replaced = await measure(page); await page.screenshot({ path: path.join(output, `${kind}-replaced-100.png`) }); await check('B', `${kind} full data replacement Fits again`, () => { assert.ok(replaced.inside && Math.abs(replaced.centerErrX) <= 1 && Math.abs(replaced.centerErrY) <= 1, JSON.stringify(replaced.margins)); return { scale: +replaced.scale.toFixed(4), centerErrX: +replaced.centerErrX.toFixed(3), centerErrY: +replaced.centerErrY.toFixed(3) }; })(); await page.close(); } // C23: outside the canvas the page scrolls; inside, the wheel zooms. for (const kind of ['vanilla', 'react']) { const page = await open(desktop, `${base}/${kind}.html?data=100&embed=1`); await ready(page); await page.mouse.move(700, 300); await page.mouse.wheel(0, 200); await page.waitForTimeout(150); const scrolled = await page.evaluate(() => scrollY); const box = await page.locator('#chart').boundingBox(); const before = await measure(page); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.wheel(0, -100); await page.waitForTimeout(150); const after = await measure(page); const scrollAfter = await page.evaluate(() => scrollY); await check('C', `${kind} page scroll outside canvas / zoom inside`, () => { assert.ok(scrolled >= 150, `outside scrollY ${scrolled}`); assert.equal(scrollAfter, scrolled, 'inside wheel does not scroll the page'); assert.ok(after.scale > before.scale); return { outsideScrollY: scrolled, insideScrollY: scrollAfter, scale: [+before.scale.toFixed(4), +after.scale.toFixed(4)] }; })(); await page.close(); } // D28: optional HM Dialog adapter. { const page = await open(desktop, `${base}/react-hm-dialog.html?data=20`); await ready(page); const view = await measure(page); await page.locator('.hm-orgchart-employee').nth(4).click(); const hmDialog = page.locator('[data-slot="dialog-content"]'); await hmDialog.waitFor({ timeout: 5000 }); await page.waitForTimeout(400); await page.screenshot({ path: path.join(output, 'react-hm-dialog-open.png') }); const text = (await hmDialog.innerText()).replace(/\s+/g, ' ').slice(0, 80); const coreDialogOpen = await page.locator('dialog.hm-orgchart-employee-dialog').evaluate(element => element.open); await page.keyboard.press('Escape'); await hmDialog.waitFor({ state: 'detached', timeout: 5000 }); const after = await measure(page); await check('D', 'react /hm-dialog Chart Only uses HM Dialog', () => { assert.equal(coreDialogOpen, false); assert.deepEqual([after.scale, after.x, after.y], [view.scale, view.x, view.y]); return { text }; })(); await page.close(); } // Preserve the untouched initial Fit through controller recreation. The manual // minimum is deliberately above Fit to exercise the saved floor without 2,100+ data. for (const kind of ['vanilla', 'react']) { const page = await open(desktop, `${base}/${kind}.html?data=500&verify-min=1`); await ready(page); const initial = await measure(page); const title = page.locator('.hm-orgchart-expanded-toggle').nth(1); await title.click(); await page.waitForTimeout(250); const collapsed = await measure(page); await check('R1', `${kind} untouched Fit survives collapse`, () => { assert.ok(initial.scale < 0.8); assert.deepEqual([collapsed.scale, collapsed.x, collapsed.y], [initial.scale, initial.x, initial.y]); })(); await title.click(); await page.waitForTimeout(250); const expanded = await measure(page); const viewport = page.locator('.hm-orgchart-viewport'); const box = await viewport.boundingBox(); await page.mouse.move(box.x + 8, box.y + box.height - 8); await page.mouse.wheel(0, 120); await page.waitForTimeout(150); const zoomed = await measure(page); await check('R2', `${kind} recreated controller retains below-min Fit floor`, () => { assert.deepEqual([expanded.scale, expanded.x, expanded.y], [initial.scale, initial.x, initial.y]); assert.equal(zoomed.scale, initial.scale, 'wheel out must not jump to 0.8'); })(); if (kind === 'vanilla') { await page.evaluate(() => window.orgChart.setInteractions({ zoom: false })); await page.evaluate(() => window.orgChart.setInteractions({ zoom: true })); const changed = await measure(page); await check('R3', 'Vanilla interaction recreation retains initial view', () => { assert.deepEqual([changed.scale, changed.x, changed.y], [initial.scale, initial.x, initial.y]); })(); } await page.close(); } await desktop.close(); // Mobile: initial Fit, touch Pan and layout only (no wheel claims). const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }); for (const kind of ['vanilla', 'react']) { for (const size of ['20', '100', '500']) { const page = await open(mobile, `${base}/${kind}.html?data=${size}`); await ready(page); const m = await measure(page); if (size !== '100') await page.screenshot({ path: path.join(output, `${kind}-${size}-mobile.png`) }); let touch; if (size === '20') { const cdp = await mobile.newCDPSession(page); const start = { x: m.canvas.left + 8, y: m.canvas.top + m.canvas.height - 8 }; await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [start] }); for (let i = 1; i <= 5; i++) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start.x + i * 12, y: start.y - i * 10 }] }); await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); const moved = await measure(page); touch = { dx: moved.x - m.x, dy: moved.y - m.y }; } await check('B', `${kind} ${size} mobile Fit${touch ? ' + touch Pan' : ''}`, () => { assert.ok(m.inside && Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, JSON.stringify(m.margins)); assert.equal(m.pageOverflowX, false); if (touch) assert.ok(Math.abs(touch.dx) > 20 && Math.abs(touch.dy) > 20, JSON.stringify(touch)); return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3), touch }; })(); await page.close(); } } await mobile.close(); await browser.close(); const failed = results.filter(result => !result.pass); await writeFile(path.join(output, 'results.json'), JSON.stringify({ base, results, consoleErrors }, null, 2)); console.log(`\n${results.length - failed.length}/${results.length} PASS · console errors ${consoleErrors.length}`); for (const error of consoleErrors) console.log('console:', error.url, error.text); if (failed.length || consoleErrors.length) process.exitCode = 1;