feat(org-chart): add core cards and fixed-layout integration

This commit is contained in:
shkim4 committed 2026-10-12 17:58:30 +09:00
1 parent d18e3c3380
commit 61c6f8d1f1
161 files changed
+13044 -1

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
# OrgChart React public-package consumer
## P3 follow-up
`p1.html?mode=readOnly` uses the updated common Core with the position matrix,
group links and employee information dialog. No navigation URLs are preset.
The development-only `window.OrgChartP1React.setOptions({ organizationLinks,
onEmployeeClick })` helper applies public React props to the current fixture;
it is not a new Core handle method. `setData` remains available.
After building Core/Wrapper and both examples, run the focused
`node examples/org-chart/verify-p3.mjs`. Evidence goes to
`test-results/org-chart-p3`; prior P1/P2 scripts and results remain unchanged.
This independent Vite consumer imports the built public entries of
`@hm-design/org-chart-react`, `@hm-design/org-chart` and `@hm-design/ui/theme.css`.
It imports the original Vanilla v1/v2 JSON files directly. It is a verification
example, with fictitious data and a `window.OrgChartReactDemo` inspection seam;
it has no server/login integration or production business controls.
From the repository root, build packages before the consumer:
```sh
npm run build:packages
npm run build:org-chart-react-example
npm run dev:org-chart-react-example
```
Development URL: `http://127.0.0.1:6026/` (strict port). Strict Mode effect replay
is checked in development. The production output has its own HTTP browser smoke
and update checks. This ESM output is for HTTP deployment; it is not a direct-open
`file://` artifact. The existing Vanilla `examples/org-chart/dist/classic.html`
remains the direct-open example.
Core CSS URL imports become deployment assets and are passed into each Shadow DOM.
The public HM Theme import emits its existing font assets. Grid/flex consumers
must allow their chart-containing items to shrink (`min-width: 0`); horizontal
overflow belongs to the Core viewport rather than the document.
Browser tooling is supplied by the verification environment, not added to the
package dependencies. Configure `ORGCHART_PLAYWRIGHT_MODULE` and optionally
`ORGCHART_CHROME`, using absolute paths. Start the separate Storybook server on
6025 and consumer on 6026, then:
```sh
# Set these environment variables using your shell's syntax:
# ORGCHART_CONSUMER_URL=http://127.0.0.1:6026
# ORGCHART_STORYBOOK_URL=http://127.0.0.1:6025
npm run test:org-chart-react-browser
node examples/org-chart-react/verify-boundaries.mjs
```
`verify.mjs` inspects real Core DOM, compares Vanilla/React snapshots, exercises
React and Storybook lifecycles and records screenshots/metrics at four widths.
`verify-boundaries.mjs` checks production updates, source identity semantics,
actual horizontal scrolling, global CSS isolation and direct-open Vanilla smoke.
Both write evidence under ignored `test-results/org-chart-react/`.
To check the actual package archive and declarations without another build:
```sh
npm pack -w @hm-design/org-chart --ignore-scripts --pack-destination test-results
npm pack -w @hm-design/org-chart-react --ignore-scripts --pack-destination test-results
node examples/org-chart-react/verify-package.mjs
```
The tarball test uses extracted Core/Wrapper packages with the environment's
React/compiler; it does not establish a fresh remote-registry installation.
## P2 read-only Full Composition
Open `p1.html?mode=readOnly` to use the same read-only Core composition as the
Vanilla example and Storybook `orgchart--full-composition`. Plain `p1.html`
retains the existing interactive mode. The query changes presentation only.
All displayed names are `홍 길 동`; the employee fixture and aggregation remain
unchanged. Department leaders use card assets by actual ancestry depth.
After building the Core, preparing the HTML example and building this consumer,
run `node examples/org-chart/verify-p2.mjs` from the repository root. Set
`ORGCHART_STORYBOOK_URL` to the current Storybook server when checking dev.
The focused verifier records screenshots and measurements in
`test-results/org-chart-p2`; it preserves existing v0/P1 test expectations.
+91
View File
@@ -0,0 +1,91 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { OrgChart, type OrgChartProps } from '@hm-design/org-chart-react';
import { JsonOrgChartDataSource, type OrgChartDataSource, type OrgChartState } from '@hm-design/org-chart';
import engineUrl from '@hm-design/org-chart/engine.css?url';
import coreUrl from '@hm-design/org-chart/style.css?url';
import v1 from '../org-chart/data-v1.json';
import v2 from '../org-chart/data-v2.json';
import './index.css';
// An independent production-built consumer of public package entries. This
// inspection seam exercises React Props only; it never reaches into the engine.
const events: { instance: string; callback: number; state: OrgChartState }[] = [];
const lifecycleErrors: string[] = [];
const requests: Record<string, { loads: number; aborts: number }> = {};
function track(name: string, source: OrgChartDataSource): OrgChartDataSource {
requests[name] = { loads: 0, aborts: 0 };
return {
load(options) {
requests[name].loads++;
options?.signal?.addEventListener('abort', () => { requests[name].aborts++; }, { once: true });
return source.load(options);
},
};
}
const noEmployees = structuredClone(v1);
noEmployees.employees = [];
for (const organization of noEmployees.organizations) organization.leaderId = null;
const noOrganizations = structuredClone(v1);
noOrganizations.organizations = [];
const sources = {
v1: track('v1', new JsonOrgChartDataSource(v1)),
v2: track('v2', new JsonOrgChartDataSource(v2)),
zeroEmployees: track('zeroEmployees', new JsonOrgChartDataSource(noEmployees)),
zeroOrganizations: track('zeroOrganizations', new JsonOrgChartDataSource(noOrganizations)),
error: track('error', { async load() { throw new Error('Fictitious source failure'); } }),
};
let releaseSlow: (() => void) | undefined;
const slowSource = track('slow', {
load() { return new Promise(resolve => { releaseSlow = () => resolve(structuredClone(v1)); }); },
});
type Configuration = {
sourceA: OrgChartDataSource;
sourceB: OrgChartDataSource;
mountedA: boolean;
mountedB: boolean;
callback: number;
aggregationPolicy: OrgChartProps['aggregationPolicy'];
stylesheetUrls: OrgChartProps['stylesheetUrls'];
className: string;
};
let configuration: Configuration = {
sourceA: sources.v1, sourceB: sources.v2, mountedA: true, mountedB: false,
callback: 1, aggregationPolicy: undefined,
stylesheetUrls: { engine: engineUrl, core: coreUrl }, className: 'consumer-chart',
};
const root = createRoot(document.getElementById('root')!);
function render(patch: Partial<Configuration> = {}) {
configuration = { ...configuration, ...patch };
const callback = configuration.callback;
const chart = (instance: 'A' | 'B', source: OrgChartDataSource) => (
<section key={instance} aria-label={`OrgChart ${instance}`} data-instance={instance}>
<OrgChart
dataSource={source}
aggregationPolicy={configuration.aggregationPolicy}
stylesheetUrls={configuration.stylesheetUrls}
className={configuration.className}
onStateChange={state => { events.push({ instance, callback, state }); }}
onLifecycleError={error => { lifecycleErrors.push(error instanceof Error ? error.message : String(error)); }}
/>
</section>
);
root.render(
<StrictMode>
<p className="consumer-note">가상 JSON 데이터 · React Strict Mode · 공개 OrgChart 패키지 사용</p>
<div className="consumer-charts">
{configuration.mountedA && chart('A', configuration.sourceA)}
{configuration.mountedB && chart('B', configuration.sourceB)}
</div>
</StrictMode>,
);
}
const demo = {
render, sources, slowSource, events, lifecycleErrors, requests,
releaseSlow: () => releaseSlow?.(),
get configuration() { return configuration; },
destroy: () => root.unmount(),
};
declare global { interface Window { OrgChartReactDemo: typeof demo } }
window.OrgChartReactDemo = demo;
render();
+9
View File
@@ -0,0 +1,9 @@
@import "@hm-design/ui/theme.css";
/* Consumer content geometry; the chart owns its internal scroll container. */
body { margin: 0; background: var(--hm-sem-bg-content); color: var(--hm-sem-fg-primary); font-family: var(--hm-font-family-base); }
main { padding: 16px; }
.consumer-charts { display: grid; gap: 24px; min-width: 0; }
.consumer-charts > section { min-width: 0; }
.consumer-chart { min-width: 0; max-width: 100%; }
.consumer-note { margin: 0 0 16px; }
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:," />
<title>HM OrgChart React Consumer</title>
</head>
<body>
<main id="root"></main>
<script type="module" src="./app.tsx"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
/* Bundler resolves the same font used by HTML and the HM Storybook host. */
@font-face {
font-family: 'Noto Sans KR';
src: url('../../public/fonts/NotoSansKR-variable.ttf') format('truetype');
font-style: normal;
font-weight: 100 900;
font-display: swap;
}
body { margin: 0; background: #ffffff; }
main { padding: 16px; }
.consumer-chart { min-width: 0; }
html[data-orgchart-mode="readOnly"] body { background: #F5F8FB; }
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HM OrgChart P1 · React Full Composition</title><link rel="icon" href="data:,"></head>
<body><main><div id="root"></div></main><script type="module" src="./p1.tsx"></script></body>
</html>
+32
View File
@@ -0,0 +1,32 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { OrgChart, type OrgChartProps } from '@hm-design/org-chart-react';
import { JsonOrgChartDataSource, type OrgChartState } from '@hm-design/org-chart';
import engineUrl from '@hm-design/org-chart/engine.css?url';
import coreUrl from '@hm-design/org-chart/style.css?url';
import fixture from '../org-chart/data-p1.json';
import './p1.css';
const root = createRoot(document.getElementById('root')!);
const events: OrgChartState[] = [];
const errors: string[] = [];
const displayMode = new URLSearchParams(location.search).get('mode') === 'readOnly' ? 'readOnly' : 'interactive';
document.documentElement.dataset.orgchartMode = displayMode;
type QueryOptions = Pick<OrgChartProps, 'organizationLinks' | 'onEmployeeClick'>;
let queryOptions: QueryOptions = {};
let currentData: unknown = fixture;
function setData(data: unknown) {
currentData = data;
root.render(<StrictMode><OrgChart
dataSource={new JsonOrgChartDataSource(data)} stylesheetUrls={{ engine: engineUrl, core: coreUrl }} className="consumer-chart"
displayMode={displayMode}
{...queryOptions}
onStateChange={state => events.push(state)} onLifecycleError={error => errors.push(String(error))}
/></StrictMode>);
}
// Optional public-prop seam for consumer integration checks; no URLs are preset.
function setOptions(options: QueryOptions) { queryOptions = options; setData(currentData); }
const demo = { data: fixture, events, errors, setData, setOptions, destroy: () => root.unmount() };
declare global { interface Window { OrgChartP1React: typeof demo } }
window.OrgChartP1React = demo;
setData(fixture);
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import path from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
const { chromium } = await import(process.env.ORGCHART_PLAYWRIGHT_MODULE ? pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href : 'playwright');
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
const file = path.resolve(root, 'examples/org-chart-react/dist', '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const base = path.join(root, 'examples/org-chart-react/dist');
const relative = path.relative(base, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; }
response.setHeader('Content-Type', { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf' }[path.extname(file)] || 'application/octet-stream');
response.end(await readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
const results = [], errors = [];
const check = (condition, name, detail) => { results.push({ name, status: condition ? 'PASS' : 'FAIL', detail }); assert.ok(condition, name); };
try {
const page = await browser.newPage({ viewport: { width: 390, height: 900 } });
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if(message.type() === 'error') errors.push(message.text()); });
await page.goto(`http://127.0.0.1:${server.address().port}`);
await page.waitForFunction(() => window.OrgChartReactDemo?.events.at(-1)?.state.status === 'ready');
check(await page.evaluate(() => window.OrgChartReactDemo.requests.v1.loads === 1 && window.OrgChartReactDemo.events.filter(event => event.state.status === 'ready').length === 1), 'production mount has one initial load and one ready callback');
const layout = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport');
viewport.scrollLeft = viewport.scrollWidth - viewport.clientWidth;
return { width: document.documentElement.clientWidth, documentScrollWidth: document.documentElement.scrollWidth, internalScrollLeft: viewport.scrollLeft };
});
check(layout.documentScrollWidth <= 390 && layout.internalScrollLeft > 0, 'production 390px page containment and actual internal horizontal scrolling', layout);
const pollution = await page.evaluate(() => {
const container = document.createElement('div'), node = document.createElement('div');
node.textContent = 'Light DOM CSS isolation probe'; container.append(node); document.body.append(container);
const read = () => { const style = getComputedStyle(node); return { width: style.width, margin: style.margin, background: style.backgroundColor, textAlign: style.textAlign, border: style.border, font: style.fontFamily }; };
const before = read(); container.className = 'orgchart'; node.className = 'node'; const after = read(); container.remove();
return { before, after };
});
check(JSON.stringify(pollution.before) === JSON.stringify(pollution.after), 'Engine selectors do not pollute light DOM', pollution);
const hostId = await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance');
await page.evaluate(async () => {
const demo = window.OrgChartReactDemo;
const next = await demo.sources.v2.load();
demo.sources.v1.load = async () => structuredClone(next);
demo.render({ callback: 9, className: 'consumer-chart boundary' });
});
await page.waitForFunction(() => document.querySelector('.consumer-chart.boundary'));
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId && await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.summary.raw.total === 4), 'same source internal mutation and unrelated render do not imply reload');
await page.evaluate(() => { const demo = window.OrgChartReactDemo; demo.render({ sourceA: { load: options => demo.sources.v1.load(options) } }); });
await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1)?.state.dataVersion === 'mock-2');
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId && await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.summary.raw.total === 5 && window.OrgChartReactDemo.events.at(-1).callback === 9), 'production source identity update uses same Core with new 5-person summary and latest callback');
await page.evaluate(() => window.OrgChartReactDemo.render({ mountedA: false }));
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host'));
check(await page.locator('.hm-orgchart-host').count() === 0, 'production unmount removes Core');
await page.goto(pathToFileURL(path.join(root, 'examples/org-chart/dist/classic.html')).href);
await page.waitForFunction(() => window.orgChartReady);
check(await page.evaluate(() => window.OrgChartDemo.chart.getState().summary.raw.total === 4), 'existing direct-open Vanilla classic remains ready with common v1 total 4');
await page.evaluate(() => window.OrgChartDemo.update());
check(await page.evaluate(() => window.OrgChartDemo.chart.getState().summary.raw.total === 5), 'existing direct-open Vanilla classic v2 update remains total 5');
check(errors.length === 0, 'boundary browser checks have zero console/runtime errors', errors);
} catch(error) {
results.push({ name: 'verification completion', status: 'FAIL', detail: error.stack }); process.exitCode = 1;
} finally {
await writeFile(path.join(root, 'test-results/org-chart-react/boundaries.json'), JSON.stringify({ browser: await browser.version(), results, errors }, null, 2));
await browser.close(); await new Promise(resolve => server.close(resolve));
console.log(JSON.stringify({ pass: results.filter(result => result.status === 'PASS').length, fail: results.filter(result => result.status === 'FAIL').length, errors }));
}
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
const root = fileURLToPath(new URL('../../', import.meta.url));
const consumer = path.join(root, 'test-results/org-chart-react/tarball-consumer');
await mkdir(consumer, { recursive: true });
for (const name of ['org-chart', 'org-chart-react']) {
const destination = path.join(consumer, 'node_modules/@hm-design', name);
await mkdir(destination, { recursive: true });
execFileSync('tar.exe', ['-xzf', path.join(root, `test-results/hm-design-${name}-0.1.0.tgz`), '-C', destination, '--strip-components=1']);
}
await writeFile(path.join(consumer, 'package.json'), JSON.stringify({ name: 'org-chart-react-tarball-consumer', private: true, type: 'module' }));
const require = createRequire(path.join(consumer, 'package.json'));
const module = await import(pathToFileURL(require.resolve('@hm-design/org-chart-react')).href);
assert.deepEqual(Object.keys(module), ['OrgChart']);
assert.equal(typeof module.OrgChart, 'function');
const manifest = JSON.parse(await readFile(path.join(consumer, 'node_modules/@hm-design/org-chart-react/package.json'), 'utf8'));
assert.deepEqual(manifest.dependencies, { '@hm-design/org-chart': '0.1.0' });
assert.deepEqual(manifest.peerDependencies, { react: '^19.0.0' });
const bundle = await readFile(require.resolve('@hm-design/org-chart-react'), 'utf8');
assert.ok(bundle.startsWith('"use client";'));
for (const dependency of ['react', 'react/jsx-runtime', '@hm-design/org-chart']) assert.ok(bundle.includes(`from "${dependency}"`) || bundle.includes(`from '${dependency}'`));
assert.ok(!bundle.includes('ManagedOrgChart') && !bundle.includes('validateOrgChartDataset') && !bundle.includes('JsonOrgChartDataSource'));
await writeFile(path.join(consumer, 'consumer.tsx'), `import { OrgChart, type OrgChartProps, type OrgChartDataSource, type OrgChartState, type OrgChartDataset } from '@hm-design/org-chart-react';
import { JsonOrgChartDataSource } from '@hm-design/org-chart';
declare const data: OrgChartDataset;
const source: OrgChartDataSource = new JsonOrgChartDataSource(data);
const props: OrgChartProps = { dataSource: source, stylesheetUrls: { engine: './engine.css', core: './style.css' }, onStateChange(state: OrgChartState) { if(state.status === 'ready') void state.summary.raw.total; }, onLifecycleError(error: unknown) { void error; } };
export const consumer = <OrgChart {...props} />;
// @ts-expect-error dataSource is a required Core source interface
export const missingSource = <OrgChart stylesheetUrls={{engine:'engine.css',core:'style.css'}} />;
// @ts-expect-error wrapper does not expose engine controls
export const unsupportedZoom = <OrgChart {...props} zoom />;
// @ts-expect-error Core-owned container cannot be supplied through React Props
export const unsupportedContainer = <OrgChart {...props} container={document.body} />;
`);
await writeFile(path.join(consumer, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', jsx: 'react-jsx', strict: true, noEmit: true, skipLibCheck: false, types: ['react'] }, include: ['consumer.tsx'] }));
const localRequire = createRequire(import.meta.url);
const typescriptManifest = localRequire.resolve('typescript/package.json');
const typescript = JSON.parse(await readFile(typescriptManifest, 'utf8'));
execFileSync(process.execPath, [path.resolve(path.dirname(typescriptManifest), typescript.bin.tsc), '-p', path.join(consumer, 'tsconfig.json')], { stdio: 'inherit' });
const report = {
status: 'PASS',
checks: ['Actual tarball runtime/public export without DOM', 'Strict NodeNext declarations using extracted Core/Wrapper', 'Required source and unsupported Props type boundaries', 'React/Core remain external', 'ESM client boundary retained'],
limitation: 'React and compiler supplied by verification environment; fresh remote-registry dependency installation and SSR/hydration are not verified.',
};
await writeFile(path.join(consumer, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report));
+312
View File
@@ -0,0 +1,312 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import path from 'node:path';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-react');
await mkdir(output, { recursive: true });
const modulePath = process.env.ORGCHART_PLAYWRIGHT_MODULE;
const { chromium } = await import(modulePath ? pathToFileURL(modulePath).href : 'playwright');
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf', '.woff2': 'font/woff2', '.svg': 'image/svg+xml' };
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(directory, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; }
response.setHeader('Content-Type', mime[path.extname(file)] || 'application/octet-stream');
response.end(await readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
return { server, origin: `http://127.0.0.1:${server.address().port}` };
}
const consumer = await serve(path.join(root, 'examples/org-chart-react/dist'));
const vanilla = await serve(path.join(root, 'examples/org-chart/dist'));
const storybook = await serve(path.join(root, 'storybook-static'));
const browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
const results = [], errors = [], metrics = [], snapshots = {};
function check(condition, name, detail) {
results.push({ name, status: condition ? 'PASS' : 'FAIL', detail });
assert.ok(condition, `${name}: ${JSON.stringify(detail)}`);
}
function captureErrors(page, transport) {
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() }); });
}
async function snapshot(page, selector = '.hm-orgchart-host') {
return page.locator(selector).first().evaluate(host => {
const shadow = host.shadowRoot;
const nodes = [...shadow.querySelectorAll('.node')];
return {
organizations: nodes.map(node => ({
id: node.dataset.organizationId,
parentId: node.parentElement.parentElement.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId || null,
employees: [...node.querySelectorAll('.hm-orgchart-employee')].map(employee => employee.dataset.employeeId),
text: node.querySelector('.hm-orgchart-organization').textContent,
})),
summary: shadow.querySelector('.hm-orgchart-summary')?.textContent,
heading: shadow.querySelector('.hm-orgchart-heading')?.textContent,
css: [...shadow.querySelectorAll('link')].map(link => ({ href: link.href, loaded: !!link.sheet })),
style: (() => { const s = getComputedStyle(shadow.querySelector('.hm-orgchart-organization')); return { color: s.color, background: s.backgroundColor, font: s.fontFamily, radius: s.borderRadius }; })(),
};
});
}
async function ready(page, instance = 'A', version) {
await page.waitForFunction(({ instance, version }) => {
const event = window.OrgChartReactDemo.events.filter(event => event.instance === instance).at(-1);
const host = document.querySelector(`[data-instance="${instance}"] .hm-orgchart-host`);
return !!host?.shadowRoot.querySelector('.hm-orgchart-shell[aria-busy="false"] .node') && event?.state.status === 'ready' && (!version || event.state.dataVersion === version);
}, { instance, version });
}
async function patch(page, value) { await page.evaluate(value => window.OrgChartReactDemo.render(value), value); }
async function source(page, name, instance = 'A') {
await page.evaluate(({ name, instance }) => window.OrgChartReactDemo.render({ [`source${instance}`]: window.OrgChartReactDemo.sources[name] }), { name, instance });
}
try {
const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
await context.addInitScript(() => {
const NativeObserver = window.MutationObserver;
const observers = new Map();
window.MutationObserver = class extends NativeObserver {
observe(...args) { observers.set(this, args[0]); return super.observe(...args); }
disconnect() { observers.delete(this); return super.disconnect(); }
};
window.chartObservers = () => [...observers.values()].filter(target => target.getRootNode()?.host?.classList.contains('hm-orgchart-host')).length;
});
const html = await context.newPage();
captureErrors(html, 'vanilla-http');
await html.goto(`${vanilla.origin}/index.html`);
await html.waitForFunction(() => window.orgChartReady);
snapshots.htmlV1 = await snapshot(html);
const page = await context.newPage();
captureErrors(page, 'react-production');
if (process.env.ORGCHART_CONSUMER_URL) {
const production = await context.newPage(); captureErrors(production, 'react-production');
await production.goto(consumer.origin); await ready(production, 'A', 'mock-1');
snapshots.reactProduction = await snapshot(production);
check(JSON.stringify(snapshots.reactProduction.organizations) === JSON.stringify(snapshots.htmlV1.organizations) && snapshots.reactProduction.css.every(link => link.loaded), 'independent production React Consumer: public imports, assets and identical HTML nodes');
await production.close();
}
await page.goto(process.env.ORGCHART_CONSUMER_URL || consumer.origin);
await ready(page, 'A', 'mock-1');
snapshots.reactV1 = await snapshot(page);
check(JSON.stringify(snapshots.reactV1.organizations) === JSON.stringify(snapshots.htmlV1.organizations), 'HTML/React v1 hierarchy, employee placement and identical node UI', snapshots.reactV1.organizations);
check(snapshots.reactV1.summary === snapshots.htmlV1.summary && snapshots.reactV1.heading === snapshots.htmlV1.heading, 'HTML/React v1 identical summary and presentation');
check(JSON.stringify(snapshots.reactV1.style) === JSON.stringify(snapshots.htmlV1.style), 'HTML/React identical computed theme', snapshots.reactV1.style);
check(snapshots.reactV1.css.length === 2 && snapshots.reactV1.css.every(link => link.loaded), 'production Shadow DOM CSS assets loaded', snapshots.reactV1.css);
const initial = await page.evaluate(() => ({ events: window.OrgChartReactDemo.events, requests: window.OrgChartReactDemo.requests, observers: window.chartObservers() }));
const expectedLoads = process.env.ORGCHART_CONSUMER_URL ? 2 : 1;
check(initial.requests.v1.loads === expectedLoads && initial.events.filter(event => event.state.status === 'ready').length === 1, 'Strict Mode: expected initial load count and single ready (development replay; production single setup)', initial);
check(await page.locator('.hm-orgchart-host').count() === 1, 'Strict Mode: exactly one active Core host');
const hostId = await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance');
await patch(page, { callback: 2, className: 'consumer-chart changed' });
await page.waitForFunction(() => document.querySelector('.consumer-chart.changed'));
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId && (await page.evaluate(() => window.OrgChartReactDemo.requests.v1.loads)) === expectedLoads, 'callback/className-only render retains Core and does not reload');
await source(page, 'v2');
await ready(page, 'A', 'mock-2');
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId, 'source identity uses reload on the same Core host');
const stateV2 = await page.evaluate(() => window.OrgChartReactDemo.events.at(-1));
check(stateV2.callback === 2 && stateV2.state.summary.raw.total === 5 && stateV2.state.summary.raw.byOrganization['team-a'].direct === 1 && stateV2.state.summary.raw.byOrganization['team-b'].direct === 3, 'latest callback and v2 expected 1/1/3 placement, total 5', stateV2);
await html.evaluate(() => window.OrgChartDemo.update());
snapshots.htmlV2 = await snapshot(html);
snapshots.reactV2 = await snapshot(page);
check(JSON.stringify(snapshots.reactV2.organizations) === JSON.stringify(snapshots.htmlV2.organizations) && snapshots.reactV2.summary === snapshots.htmlV2.summary, 'HTML/React v2 data, hierarchy, employee nodes and aggregation parity');
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1100 });
await page.evaluate(() => document.fonts.ready);
const metric = await page.locator('.hm-orgchart-host').evaluate(host => {
const shadow = host.shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport');
const rectangles = [...shadow.querySelectorAll('.node')].map(node => node.getBoundingClientRect());
const overlap = rectangles.some((a, index) => rectangles.slice(index + 1).some(b => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1));
const organization = shadow.querySelector('.hm-orgchart-organization'), style = getComputedStyle(organization);
const probe = document.createElement('div');
probe.style.color = 'var(--hm-sem-fg-primary)'; probe.style.background = 'var(--hm-sem-bg-surface)'; probe.style.borderRadius = 'var(--hm-radius-container)'; shadow.append(probe);
const expectedStyle = getComputedStyle(probe), expected = { color: expectedStyle.color, background: expectedStyle.backgroundColor, radius: expectedStyle.borderRadius };
const match = expected.color === style.color && expected.background === style.backgroundColor && expected.radius === style.borderRadius;
probe.remove();
const nodes = [...shadow.querySelectorAll('.node')], rect = nodes[0].getBoundingClientRect();
const centers = nodes.slice(1).map(node => { const r = node.getBoundingClientRect(); return r.x + r.width / 2; });
return {
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
host: { clientWidth: host.clientWidth, scrollWidth: host.scrollWidth, clientHeight: host.clientHeight, scrollHeight: host.scrollHeight },
viewport: { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX },
overlap, themeMatch: match, expected, color: style.color, background: style.backgroundColor, radius: style.borderRadius, font: style.fontFamily,
fontLoaded: document.fonts.check('13px "Noto Sans KR"'), idWhiteSpace: getComputedStyle(shadow.querySelector('.hm-orgchart-domain-id')).whiteSpace,
connectorsAligned: Math.abs(rect.x + rect.width / 2 - (centers[0] + centers[1]) / 2) < 2,
connectorTheme: getComputedStyle(nodes[1], '::before').backgroundColor === getComputedStyle(viewport).borderTopColor,
};
});
metrics.push({ width, ...metric });
check(metric.document.scrollWidth <= width && metric.host.scrollWidth <= metric.host.clientWidth && metric.viewport.overflowX === 'auto' && !metric.overlap, `${width}px container overflow/scroll/no overlap`, metric);
check(metric.themeMatch && metric.font.includes('Noto Sans KR') && metric.fontLoaded && metric.idWhiteSpace === 'nowrap' && metric.connectorsAligned && metric.connectorTheme, `${width}px theme/fonts/IDs/connectors`, metric);
await page.screenshot({ path: path.join(output, `react-consumer-${width}.png`), fullPage: true });
}
const toggle = page.locator('.hm-orgchart-toggle').first();
await toggle.focus(); await toggle.press('Enter');
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
check(await toggle.getAttribute('aria-expanded') === 'false', 'React keyboard Enter collapses');
await toggle.press('Space');
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
check(await toggle.getAttribute('aria-expanded') === 'true', 'React keyboard Space expands');
await source(page, 'v1'); await ready(page, 'A', 'mock-1');
check(await page.locator('.hm-orgchart-viewport').evaluate(element => element.getRootNode().activeElement === element), 'source reload restores removed keyboard focus to stable viewport');
await page.evaluate(() => window.OrgChartReactDemo.render({ sourceA: window.OrgChartReactDemo.slowSource }));
await page.waitForFunction(() => window.OrgChartReactDemo.requests.slow.loads === 1);
await source(page, 'v2'); await ready(page, 'A', 'mock-2');
const beforeLate = await page.evaluate(() => window.OrgChartReactDemo.events.length);
await page.evaluate(() => window.OrgChartReactDemo.releaseSlow());
await page.waitForTimeout(100);
check(await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.dataVersion === 'mock-2' && window.OrgChartReactDemo.requests.slow.aborts === 1) && (await page.evaluate(() => window.OrgChartReactDemo.events.length)) === beforeLate, 'superseded abort-ignoring source cannot update DOM/callback');
await source(page, 'error');
await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.status === 'error');
check(await page.locator('.hm-orgchart-issues').count() === 1, 'source failure controlled error state');
await source(page, 'v1'); await ready(page, 'A', 'mock-1');
check(await page.locator('.hm-orgchart-issues').count() === 0, 'Error to Ready recovery via source replacement');
await source(page, 'zeroEmployees'); await ready(page);
check((await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.summary.raw.total)) === 0, 'zero employees remains Ready with total 0');
await source(page, 'zeroOrganizations');
await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.status === 'error');
check(await page.locator('.hm-orgchart-issues').count() === 1, 'zero organizations is Error; no fabricated empty state');
await source(page, 'v1'); await ready(page, 'A', 'mock-1');
await page.evaluate(() => window.OrgChartReactDemo.render({ aggregationPolicy: { policyVersion: 'consumer-test', includedStatuses: ['active'], activeStatuses: ['active'], includeLeaders: true, membershipMode: 'primaryOnly' } }));
await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.aggregation?.status === 'ready');
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') !== hostId && (await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.aggregation.counts.total)) === 2, 'policy change safely recreates Core and uses explicit test policy');
let currentHost = await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance');
await page.evaluate(() => window.OrgChartReactDemo.render({ stylesheetUrls: { ...window.OrgChartReactDemo.configuration.stylesheetUrls } }));
await page.waitForTimeout(70);
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === currentHost, 'equal stylesheet URL values retain Core');
await page.evaluate(() => { const urls = window.OrgChartReactDemo.configuration.stylesheetUrls; window.OrgChartReactDemo.render({ stylesheetUrls: { engine: urls.engine + '?consumer=2', core: urls.core }, aggregationPolicy: undefined }); });
await page.waitForFunction(oldId => document.querySelector('.hm-orgchart-host')?.dataset.orgchartInstance !== oldId, currentHost);
await ready(page);
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') !== currentHost && (await snapshot(page)).css.every(link => link.loaded), 'changed stylesheet URL safely recreates and loads assets');
await patch(page, { mountedB: true }); await ready(page, 'B', 'mock-2');
const bHost = await page.locator('[data-instance="B"] .hm-orgchart-host').getAttribute('data-orgchart-instance');
const bSnapshot = await snapshot(page, '[data-instance="B"] .hm-orgchart-host');
await source(page, 'v2'); await ready(page, 'A', 'mock-2');
check(await page.locator('.hm-orgchart-host').count() === 2 && JSON.stringify(await snapshot(page, '[data-instance="B"] .hm-orgchart-host')) === JSON.stringify(bSnapshot), 'two simultaneous instances; A update preserves B');
await page.evaluate(() => { document.querySelector('[data-instance="A"]').style.setProperty('--hm-sem-bg-surface', getComputedStyle(document.documentElement).getPropertyValue('--hm-sem-bg-content')); });
check((await snapshot(page, '[data-instance="A"] .hm-orgchart-host')).style.background !== bSnapshot.style.background && (await snapshot(page, '[data-instance="B"] .hm-orgchart-host')).style.background === bSnapshot.style.background, 'scoped existing Theme variable change inherits through Shadow DOM and isolates B');
await patch(page, { mountedA: false });
await page.waitForFunction(() => !document.querySelector('[data-instance="A"]'));
check(await page.locator('.hm-orgchart-host').count() === 1 && await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === bHost, 'A unmount preserves B and its Core identity');
const bObservers = await page.evaluate(() => window.chartObservers());
for (let iteration = 0; iteration < 5; iteration++) {
await patch(page, { mountedA: true }); await ready(page, 'A', 'mock-2');
await page.waitForFunction(() => document.querySelectorAll('.hm-orgchart-host').length === 2);
await patch(page, { mountedA: false });
await page.waitForFunction(() => document.querySelectorAll('.hm-orgchart-host').length === 1);
}
check(await page.evaluate(() => window.chartObservers()) === bObservers, 'five Strict Mode mount/unmount cycles preserve B observer baseline');
await patch(page, { mountedA: true }); await ready(page, 'A', 'mock-2');
await page.evaluate(() => window.OrgChartReactDemo.render({ sourceA: window.OrgChartReactDemo.slowSource }));
await page.waitForFunction(() => window.OrgChartReactDemo.requests.slow.loads === 2);
await patch(page, { mountedA: false });
await page.waitForFunction(() => !document.querySelector('[data-instance="A"]'));
const unmountEvents = await page.evaluate(() => window.OrgChartReactDemo.events.length);
await page.evaluate(() => window.OrgChartReactDemo.releaseSlow()); await page.waitForTimeout(100);
check((await page.evaluate(() => window.OrgChartReactDemo.events.length)) === unmountEvents, 'late response after unmount cannot emit callback');
check((await page.evaluate(() => window.OrgChartReactDemo.lifecycleErrors.length)) === 0, 'normal lifecycle has no rejected-use errors');
await patch(page, { mountedA: true, stylesheetUrls: { engine: '', core: '' } });
await page.waitForFunction(() => window.OrgChartReactDemo.lifecycleErrors.length > 0);
check(await page.locator('.hm-orgchart-host').count() === 0, 'invalid Core configuration is reported via lifecycle error without leaked hosts');
await page.evaluate(() => window.OrgChartReactDemo.destroy());
check(await page.locator('.hm-orgchart-host').count() === 0 && await page.evaluate(() => window.chartObservers()) === 0, 'terminal React root unmount removes all Core DOM/observers');
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
const stories = Object.values(index.entries).filter(entry => entry.title === 'OrgChart/Content' && entry.type === 'story');
check(stories.length === 8, 'production Storybook registers exactly eight OrgChart stories', stories.map(story => ({ id: story.id, name: story.name })));
const sb = await context.newPage(); captureErrors(sb, 'storybook-production');
for (const story of stories) {
await sb.goto(`${storybook.origin}/iframe.html?id=${story.id}&viewMode=story`);
await sb.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-host')].some(host => host.shadowRoot.querySelector('.hm-orgchart-organization, .hm-orgchart-issues, .hm-orgchart-state:not(:empty)')));
if (story.id.includes('loading')) {
check((await sb.locator('.hm-orgchart-state').allTextContents()).some(text => text.includes('불러오는')), `${story.name}: actual loading source`);
} else {
await sb.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-host')].every(host => host.shadowRoot.querySelector('.hm-orgchart-organization')));
check(await sb.locator('.node').count() >= 3, `${story.name}: real Core nodes rendered`);
}
if (story.id.includes('json-data-update')) {
await sb.getByRole('button', { name: 'JSON v2', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
check((await sb.locator('.hm-orgchart-raw-total').textContent()).includes('5명'), 'Storybook JSON v1 to v2 recalculates 5 employees');
await sb.getByRole('button', { name: 'Unmount', exact: true }).click();
check(await sb.locator('.hm-orgchart-host').count() === 0, 'Storybook Unmount removes Core');
await sb.getByRole('button', { name: 'Mount', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.node'));
check(await sb.locator('.hm-orgchart-host').count() === 1, 'Storybook remount creates one Core');
}
if (story.id.includes('loading')) {
await sb.getByRole('button', { name: 'Source Error', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('error'));
check(await sb.locator('.hm-orgchart-issues').count() === 1, 'Storybook source error controlled by Core');
await sb.getByRole('button', { name: 'Ready v2 / 복구', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
check(await sb.locator('.node').count() === 3, 'Storybook Error to Ready recovery');
await sb.getByRole('button', { name: '직원 0명 (Ready)', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('story-zero-employees'));
check((await sb.locator('.hm-orgchart-raw-total').textContent()).includes('0명'), 'Storybook zero employees Ready');
await sb.getByRole('button', { name: '조직 0개 (Error)', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('error'));
check(await sb.locator('.hm-orgchart-issues').count() === 1, 'Storybook zero organizations Error');
await sb.getByRole('button', { name: '느린 v1 (취소 무시)', exact: true }).click();
await sb.getByRole('button', { name: 'Ready v2 / 복구', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
const outputBefore = await sb.locator('output').textContent();
await sb.waitForTimeout(4100);
check(await sb.locator('output').textContent() === outputBefore, 'Storybook stale abort-ignoring response produces no callback');
}
if (story.id.includes('multiple-instances')) {
const b = sb.getByRole('region', { name: 'OrgChart B', exact: true });
const bIdentity = await b.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance');
const bState = await b.locator('output').textContent();
await sb.getByRole('button', { name: 'A → JSON v2', exact: true }).click();
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
await sb.getByRole('button', { name: 'A Unmount', exact: true }).click();
check(await sb.locator('.hm-orgchart-host').count() === 1 && await b.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === bIdentity && await b.locator('output').textContent() === bState, 'Storybook A update/unmount leaves B identity, data and callback count unchanged');
}
if (story.id.includes('basic-organization') || story.id.includes('employee-cards') || story.id.includes('responsive-container')) {
for (const width of [390, 768, 1280, 1440]) {
await sb.setViewportSize({ width, height: 1100 });
if (story.id.includes('responsive-container')) await sb.getByRole('button', { name: `${width}px`, exact: true }).click();
const dimensions = await sb.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const nodes = [...shadow.querySelectorAll('.node')], viewport = shadow.querySelector('.hm-orgchart-viewport');
const rects = nodes.map(node => node.getBoundingClientRect());
return {
clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth,
viewport: { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight },
nodeWidths: rects.map(rect => rect.width),
overlap: rects.some((a, index) => rects.slice(index + 1).some(b => Math.min(a.right,b.right)-Math.max(a.left,b.left)>1 && Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top)>1)),
avatars: [...shadow.querySelectorAll('.hm-orgchart-avatar')].every(avatar => avatar.textContent && !avatar.querySelector('img')),
textFits: [...shadow.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-organization-name')].every(element => element.scrollWidth <= element.clientWidth),
};
});
metrics.push({ story: story.name, width, ...dimensions });
check(dimensions.scrollWidth <= width && !dimensions.overlap && dimensions.textFits && dimensions.avatars, `${story.name}: ${width}px document scroll/overlap/long text/profile fallback`, dimensions);
await sb.screenshot({ path: path.join(output, `${story.id}-${width}.png`), fullPage: true });
}
}
await sb.screenshot({ path: path.join(output, `${story.id}.png`), fullPage: true });
}
if (process.env.ORGCHART_STORYBOOK_URL) {
await sb.goto(`${process.env.ORGCHART_STORYBOOK_URL}/iframe.html?id=orgchart-content--basic-organization&viewMode=story`);
await sb.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.hm-orgchart-organization'));
check(await sb.locator('.node').count() === 3, 'development Storybook on reserved separate port executes real Wrapper');
}
check(errors.length === 0, 'browser console/runtime errors are zero across consumer, Vanilla and Storybook', errors);
} catch (error) {
results.push({ name: 'verification completion', status: 'FAIL', detail: error.stack });
process.exitCode = 1;
} finally {
await writeFile(path.join(output, 'report.json'), JSON.stringify({ browser: await browser.version(), results, errors, metrics, snapshots }, null, 2));
await browser.close();
for (const { server } of [consumer, vanilla, storybook]) await new Promise(resolve => server.close(resolve));
console.log(JSON.stringify({ pass: results.filter(result => result.status === 'PASS').length, fail: results.filter(result => result.status === 'FAIL').length, errors, report: path.join(output, 'report.json') }));
}
+11
View File
@@ -0,0 +1,11 @@
import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
root: path.resolve(import.meta.dirname),
base: './',
plugins: [react()],
server: { host: '127.0.0.1', port: 6026, strictPort: true },
build: { outDir: 'dist', emptyOutDir: true, rolldownOptions: { input: { index: path.resolve(import.meta.dirname, 'index.html'), p1: path.resolve(import.meta.dirname, 'p1.html') } } },
});