import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; import assert from 'node:assert/strict'; import http from 'node:http'; import { readFile, mkdir, writeFile } from 'node:fs/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; import path from 'node:path'; // Browser tooling belongs to the verification environment, not the production package. await loadPlaywright(); const example = fileURLToPath(new URL('./dist/', import.meta.url)); const output = fileURLToPath(new URL('../../test-results/org-chart-browser/', import.meta.url)); await mkdir(output, { recursive: true }); const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf' }; const server = http.createServer(async (request, response) => { try { const url = new URL(request.url, 'http://localhost'); const file = path.resolve(example, '.' + decodeURIComponent(url.pathname)); const relative = path.relative(example, file); if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; } const data = await readFile(file); response.setHeader('Content-Type', mime[path.extname(file)] || 'application/octet-stream'); response.end(data); } 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 results = [], errors = [], metrics = []; const check = (condition, name, detail) => { results.push({ name, status: condition ? 'PASS' : 'FAIL', detail }); assert.ok(condition, `${name}: ${JSON.stringify(detail)}`); }; try { const context = await browser.newContext({ viewport: { width: 1440, height: 980 } }); await context.addInitScript(() => { const NativeObserver = window.MutationObserver; const activeObservers = new Map(); window.MutationObserver = class extends NativeObserver { observe(...args) { activeObservers.set(this, args[0]); return super.observe(...args); } disconnect() { activeObservers.delete(this); return super.disconnect(); } }; const nativeAdd = EventTarget.prototype.addEventListener; const nativeRemove = EventTarget.prototype.removeEventListener; const listeners = []; const capture = options => typeof options === 'boolean' ? options : !!options?.capture; EventTarget.prototype.addEventListener = function(type, callback, options) { if ((this === window || this === document) && callback && !listeners.some(item => item.target === this && item.type === type && item.callback === callback && item.capture === capture(options))) { const item = { target: this, type, callback, capture: capture(options) }; listeners.push(item); options?.signal?.addEventListener('abort', () => { const i = listeners.indexOf(item); if (i >= 0) listeners.splice(i, 1); }, { once: true }); } return nativeAdd.call(this, type, callback, options); }; EventTarget.prototype.removeEventListener = function(type, callback, options) { const index = listeners.findIndex(item => item.target === this && item.type === type && item.callback === callback && item.capture === capture(options)); if (index >= 0) listeners.splice(index, 1); return nativeRemove.call(this, type, callback, options); }; window.orgChartResources = () => ({ observers: activeObservers.size, chartObservers: [...activeObservers.values()].filter(target=>target.getRootNode()?.host?.classList.contains('hm-orgchart-host')).length, globalListeners: listeners.map(item => `${item.target === document ? 'document' : 'window'}:${item.type}`).sort(), }); }); for (const [transport, url] of [['http-esm', `${origin}/index.html`], ['file-classic', pathToFileURL(path.join(example, 'classic.html')).href]]) { const page = await context.newPage(); page.on('pageerror', error => errors.push({ transport, type: 'pageerror', message: error.message })); page.on('console', message => { if (message.type() === 'error') errors.push({ transport, type: 'console', message: message.text() }); }); await page.goto(url); await page.waitForFunction(() => window.orgChartReady); const state = await page.evaluate(() => { const root = window.OrgChartDemo.container.firstElementChild.shadowRoot; return { state: window.OrgChartDemo.chart.getState(), nodes: root.querySelectorAll('.node').length, topLeaders: [...root.querySelectorAll('.hm-orgchart-top-leaders [data-employee-id]')].map(card => card.dataset.employeeId), shownEmployees: [...root.querySelectorAll('.hm-orgchart-top-leaders [data-employee-id], .hm-orgchart-forest [data-employee-id]')].map(card => card.dataset.employeeId).sort() }; }); await page.screenshot({path:path.join(output, `${transport}-initial.png`),fullPage:true}); check(state.state.status === 'ready', `${transport}: initial ready state`, state); // Composition contract: a root whose only direct employee is its leader shows that // leader as the standalone top card, and its child organizations become the engine roots. check(state.nodes === 2 && state.topLeaders.join() === 'emp-a' && state.shownEmployees.join() === 'emp-a,emp-b,emp-c,emp-d' && state.state.summary.raw.total === 4, `${transport}: mount + JSON + custom nodes`, state); check(state.state.aggregation.status === 'unavailable', `${transport}: no hidden business policy`); const baseline = await page.evaluate(() => window.orgChartResources()); for (const width of [1440, 1280, 768, 390]) { await page.setViewportSize({ width, height: 980 }); await page.evaluate(() => document.fonts.ready); const measurement = await page.evaluate(() => { const root = window.OrgChartDemo.container.firstElementChild.shadowRoot; const node = root.querySelector('.hm-orgchart-organization'); const style = getComputedStyle(node); const host = window.OrgChartDemo.container; const viewport = root.querySelector('.hm-orgchart-viewport'); return { document: { width: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, height: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight }, host: { width: host.clientWidth, scrollWidth: host.scrollWidth, height: host.clientHeight, scrollHeight: host.scrollHeight }, viewport: viewport && { width: viewport.clientWidth, scrollWidth: viewport.scrollWidth, height: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX }, color: style.color, background: style.backgroundColor, font: style.fontFamily, radius: style.borderRadius, themeColor: getComputedStyle(host).getPropertyValue('--hm-sem-fg-primary').trim(), themeBackground: getComputedStyle(host).getPropertyValue('--hm-sem-bg-surface').trim(), themeRadius: getComputedStyle(host).getPropertyValue('--hm-radius-container').trim(), idWhiteSpace: getComputedStyle(root.querySelector('.hm-orgchart-domain-id')).whiteSpace, fontLoaded: document.fonts.check('13px "Noto Sans KR"'), connectorColor: getComputedStyle([...root.querySelectorAll('.node')][1], '::before').backgroundColor, borderColor: getComputedStyle(viewport).borderTopColor, parentCenter: (()=>{const n=root.querySelector('.node').getBoundingClientRect();return n.x+n.width/2;})(), childCenters: [...root.querySelectorAll('.node')].slice(1).map(element=>{const n=element.getBoundingClientRect();return n.x+n.width/2;}), }; }); metrics.push({ transport, width, ...measurement }); check(measurement.document.scrollWidth <= width, `${transport}: ${width}px document overflow`, measurement); check(measurement.themeColor && measurement.background !== 'rgba(0, 0, 0, 0)' && measurement.font.includes('Noto Sans KR') && measurement.fontLoaded, `${transport}: ${width}px HM theme + font`, measurement); check(measurement.idWhiteSpace === 'nowrap', `${transport}: ${width}px one-line organization IDs`); check(measurement.connectorColor === measurement.borderColor && Math.abs(measurement.parentCenter - (measurement.childCenters[0]+measurement.childCenters[1])/2)<2, `${transport}: ${width}px themed + aligned hierarchy connectors`, measurement); await page.screenshot({ path: path.join(output, `${transport}-${width}.png`), fullPage: true }); } // Shadow DOM buttons have native keyboard activation and retain focus on collapse. const toggle = page.locator('.hm-orgchart-toggle').first(); await toggle.focus(); await toggle.press('Enter'); await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding')); check(await toggle.getAttribute('aria-expanded') === 'false', `${transport}: keyboard collapse`); check(await toggle.evaluate(element => element.getRootNode().activeElement === element), `${transport}: collapse focus retained`); await toggle.press('Space'); await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding')); check(await toggle.getAttribute('aria-expanded') === 'true', `${transport}: keyboard expand`); await toggle.focus(); await page.evaluate(() => window.OrgChartDemo.update()); check(await page.evaluate(() => { const root = window.OrgChartDemo.container.firstElementChild.shadowRoot; return root.activeElement?.classList.contains('hm-orgchart-viewport'); }), `${transport}: focus returns to viewport after regeneration`); const updated = await page.evaluate(() => window.OrgChartDemo.chart.getState()); check(updated.status === 'ready' && updated.summary.raw.total === 5 && updated.summary.raw.byOrganization['team-a'].direct === 1 && updated.summary.raw.byOrganization['team-b'].direct === 3, `${transport}: update + movement + add + recalculated counts`, updated); await page.evaluate(() => window.OrgChartDemo.chart.reload()); check(await page.evaluate(() => window.OrgChartDemo.chart.getState().summary.raw.total) === 4, `${transport}: reload source remains initial`); await page.evaluate(async () => { const demo = window.OrgChartDemo; const data = structuredClone(await demo.source().load()); data.organizations.push({id:'depth-three',parentId:'team-a',name:'가상 하위 셀',type:'cell',leaderId:null,displayOrder:0}); await demo.chart.update(data); }); const rootToggle = page.locator('.hm-orgchart-toggle').first(); await rootToggle.click(); await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding')); await rootToggle.click(); await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding')); const nested = await page.evaluate(() => { const root = window.OrgChartDemo.container.firstElementChild.shadowRoot; const node = [...root.querySelectorAll('.node')].find(node=>node.dataset.organizationId==='team-a'); const grandchild = [...root.querySelectorAll('.node')].find(node=>node.dataset.organizationId==='depth-three'); return {expanded:node.querySelector('.hm-orgchart-toggle').getAttribute('aria-expanded'),visible:grandchild.getBoundingClientRect().height > 0 && !grandchild.closest('.isCollapsedDescendant')}; }); check((nested.expanded === 'true') === nested.visible, `${transport}: depth-three descendant toggle matches visibility`, nested); await page.evaluate(() => window.OrgChartDemo.chart.reload()); const lifecycle = await page.evaluate(async () => { const { api, chart, source, stylesheetUrls, container } = window.OrgChartDemo; const initial = await source().load(); const changed = structuredClone(initial); changed.metadata.dataVersion = 'newest'; changed.employees.pop(); let resolveOld, oldSignal; const oldSource = { load({signal}) { oldSignal = signal; return new Promise(resolve => { resolveOld = resolve; }); } }; const pending = chart.reload(oldSource); await chart.update(changed); changed.employees.length = 0; resolveOld(initial); await pending; const race = { version: chart.getState().dataVersion, total: chart.getState().summary.raw.total, aborted: oldSignal.aborted }; await chart.reload({load: async () => { throw new Error('private server detail'); }}); const sourceError = chart.getState(); const invalid = structuredClone(initial); invalid.organizations[0].name = ''; await chart.update(invalid); const validationError = chart.getState(); await chart.reload(source()); const sibling = document.createElement('p'); sibling.textContent = 'host sibling'; container.append(sibling); const secondHost = document.createElement('div'); secondHost.setAttribute('data-hm-theme', ''); secondHost.style.setProperty('--hm-sem-fg-primary', getComputedStyle(container).getPropertyValue('--hm-sem-fg-secondary')); secondHost.style.setProperty('--hm-radius-container', '0px'); container.after(secondHost); const second = api.createOrgChart({container:secondHost, dataSource:source(), stylesheetUrls}); await second.mount(); const firstRoot = container.firstElementChild.shadowRoot, secondRoot = secondHost.firstElementChild.shadowRoot; const firstIds = [...firstRoot.querySelectorAll('[id]')].map(element => element.id); const secondIds = [...secondRoot.querySelectorAll('[id]')].map(element => element.id); const scopes = { distinctIds: !firstIds.some(id => secondIds.includes(id)), firstColor:getComputedStyle(firstRoot.querySelector('.hm-orgchart-organization')).color, secondColor:getComputedStyle(secondRoot.querySelector('.hm-orgchart-organization')).color, secondRadius:getComputedStyle(secondRoot.querySelector('.hm-orgchart-organization')).borderRadius }; chart.destroy(); chart.destroy(); const destroy = { remaining:container.children.length, siblingPreserved:sibling.isConnected, secondReady:second.getState().status === 'ready' }; second.destroy(); secondHost.remove(); sibling.remove(); for (let i = 0; i < 5; i++) { const next = api.createOrgChart({container, dataSource:source(), stylesheetUrls}); await next.mount(); await next.reload(); next.destroy(); } const resources = window.orgChartResources(); const reentrantHost = document.createElement('div'); container.after(reentrantHost); let reentrant; reentrant = api.createOrgChart({container:reentrantHost,stylesheetUrls,dataSource:source(),onStateChange(state){if(state.status==='loading')reentrant.destroy();}}); await reentrant.mount(); const reentrantDestroy = reentrantHost.children.length===0 && reentrant.getState().status==='destroyed'; reentrantHost.remove(); const pendingHost = document.createElement('div'); container.after(pendingHost); let resolvePending, signalPending, callbackCount = 0; const destroyed = api.createOrgChart({container:pendingHost,stylesheetUrls,dataSource:{load({signal}){signalPending=signal;return new Promise(resolve=>{resolvePending=resolve;});}},onStateChange(){callbackCount++;}}); const mounting = destroyed.mount(); // Allow stylesheet loading before the source is entered. while (!resolvePending) await new Promise(resolve=>setTimeout(resolve,0)); destroyed.destroy(); const atDestroy = callbackCount; resolvePending(initial); await mounting; const late = {children:pendingHost.children.length,aborted:signalPending.aborted,callbacksAfterDestroy:callbackCount-atDestroy}; pendingHost.remove(); await window.OrgChartDemo.create(); return {race,sourceError,validationError,scopes,destroy,resources,late,reentrantDestroy}; }); check(lifecycle.race.version === 'newest' && lifecycle.race.total === 3 && lifecycle.race.aborted, `${transport}: stale response aborted + immutable update`, lifecycle.race); check(lifecycle.sourceError.status === 'error' && !JSON.stringify(lifecycle.sourceError).includes('private server detail'), `${transport}: sanitized loading failure`, lifecycle.sourceError); check(lifecycle.validationError.status === 'error' && lifecycle.validationError.issues.some(issue=>issue.code==='UNSAFE_INPUT'), `${transport}: validation blocks engine payload`, lifecycle.validationError); check(lifecycle.scopes.distinctIds && lifecycle.scopes.firstColor !== lifecycle.scopes.secondColor && lifecycle.scopes.secondRadius === '0px', `${transport}: multiple instances + scoped theme`, lifecycle.scopes); check(lifecycle.destroy.remaining === 1 && lifecycle.destroy.siblingPreserved && lifecycle.destroy.secondReady, `${transport}: destroy preserves host sibling + other instance`, lifecycle.destroy); check(lifecycle.resources.chartObservers === 0 && lifecycle.resources.observers === baseline.observers && JSON.stringify(lifecycle.resources.globalListeners) === JSON.stringify(baseline.globalListeners), `${transport}: repeated lifecycle resource stability`, { baseline, after:lifecycle.resources }); check(lifecycle.late.children === 0 && lifecycle.late.aborted && lifecycle.late.callbacksAfterDestroy === 0, `${transport}: destroy suppresses late callback`, lifecycle.late); check(lifecycle.reentrantDestroy, `${transport}: reentrant loading callback destroy`); await page.close(); } check(errors.length === 0, 'No browser runtime/console errors', errors); } catch (error) { results.push({ name: 'Harness completion', status: 'FAIL', detail: error.stack }); process.exitCode = 1; } finally { await writeFile(path.join(output, 'report.json'), JSON.stringify({ browser:browser.version(), results, errors, metrics, limits:['Full heap/GC recovery not verified','Screen reader announcement not verified','No visual reference supplied','Advanced responsive layout out of scope'] }, null, 2)); await browser.close(); await new Promise(resolve => server.close(resolve)); } console.log(JSON.stringify({ passed:results.filter(row=>row.status==='PASS').length, failed:results.filter(row=>row.status==='FAIL'), report:path.join(output,'report.json') }, null, 2));