Files
HMDesign/examples/org-chart/verify.mjs
T

219 lines
18 KiB
JavaScript

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.
const playwright = process.env.ORGCHART_PLAYWRIGHT_MODULE;
const { chromium } = await import(playwright ? pathToFileURL(playwright).href : 'playwright');
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 chromium.launch({ executablePath: process.env.ORGCHART_CHROME || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 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 = '<img src=x onerror=alert(1)>';
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));