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') } } },
});
+32
View File
@@ -0,0 +1,32 @@
export function startDemo(api, inlineFixtures) {
const { createOrgChart, JsonOrgChartDataSource } = api;
const container = document.getElementById('chart-host');
const status = document.getElementById('status');
const stylesheetUrls = {
engine: new URL('./assets/engine.css', location.href).href,
core: new URL('./assets/style.css', location.href).href,
};
const source = () => new JsonOrgChartDataSource(inlineFixtures ? inlineFixtures.initial : './data-v1.json');
let chart;
function create() {
chart?.destroy();
chart = createOrgChart({ container, dataSource: source(), stylesheetUrls, onStateChange(state) {
status.textContent = state.status === 'ready' ? `Ready · ${state.dataVersion}`
: state.status === 'error' ? `Error · ${state.issues.map(issue => issue.code).join(', ')}` : state.status;
} });
return chart.mount();
}
async function update() {
const data = inlineFixtures ? inlineFixtures.changed : await (await fetch('./data-v2.json')).json();
await chart.update(data);
}
function destroy() { chart.destroy(); status.textContent = 'Destroyed · 새 인스턴스로 다시 생성할 수 있습니다.'; }
const actions = { reload: () => chart.reload(), update, destroy, recreate: create };
for (const [id, action] of Object.entries(actions)) document.getElementById(id).addEventListener('click', () => {
Promise.resolve().then(action).catch(() => { status.textContent = 'Core 호출 순서를 확인하세요.'; });
});
window.addEventListener('pagehide', () => chart.destroy(), { once: true });
// This example's inspection seam uses only public Core APIs, never engine internals.
window.OrgChartDemo = { api, container, stylesheetUrls, source, create, update, destroy, get chart() { return chart; } };
create().then(() => { window.orgChartReady = true; }).catch(() => { status.textContent = 'Mount failed'; });
}
@@ -0,0 +1,91 @@
"""Read browser PNG backgrounds beside text; never edit production images.
Sample positions cover DOM text line bounds, with a clear padding-column probe
for the current vertical opaque gradients. Pixel sampling and the sRGB CSS model
are distinct evidence; unsupported/clipped painting is NOT VERIFIED.
"""
import json
import math
import re
import sys
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / 'test-results' / 'org-chart-quality'
report = json.loads((OUT / (sys.argv[1] if len(sys.argv) > 1 else 'browser.json')).read_text(encoding='utf-8'))
def rgb(value):
match = re.fullmatch(r'rgba?\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)(?:,\s*(1))?\)', value)
return tuple(map(float, match.groups()[:3])) if match else None
def luminance(color):
def linear(channel):
encoded = channel / 255
return encoded / 12.92 if encoded <= .04045 else ((encoded + .055) / 1.055) ** 2.4
return sum(weight * linear(channel) for weight, channel in zip((.2126, .7152, .0722), color))
def contrast(first, second):
a, b = sorted((luminance(first), luminance(second)))
return (b + .05) / (a + .05)
evidence = []
for measurement in report['measurements']:
image = Image.open(OUT / measurement['screenshot']).convert('RGB')
for item in measurement['colors']:
foreground = rgb(item['color'])
behind = item['behind']
entry = dict(label=measurement['label'], width=measurement['width'], selector=item['className'],
organizationId=item.get('organizationId'), variant=item.get('variant'), target=item.get('target'),
foreground=item['color'], fontSize=item['fontSize'], fontWeight=item['fontWeight'])
threshold = 3 if item['fontSize'] >= 24 or (float(item['fontWeight']) >= 700 and item['fontSize'] >= 14 * 96 / 72) else 4.5
entry['threshold'] = threshold
if not foreground or not behind:
entry.update(status='NOT VERIFIED', reason='Unknown foreground/background composition')
elif behind['gradient'] == 'none':
background = rgb(behind['background'])
if background:
value = contrast(foreground, background)
entry.update(status='PASS' if value >= threshold else 'REVIEW', method='computed opaque solid', ratio=value, background=behind['background'])
else:
entry.update(status='NOT VERIFIED', reason='Transparent/unsupported solid composition')
else:
stops = re.findall(r'rgb\((\d+),\s*(\d+),\s*(\d+)\)\s+(0|100)%', behind['gradient'])
box, lines, clip = behind['box'], item['ranges'], item.get('clip')
angle = re.search(r'linear-gradient\(([^r][^,]*),', behind['gradient'])
if len(stops) != 2 or not lines or (angle and angle.group(1).strip() != '180deg'):
entry.update(status='NOT VERIFIED', reason='Unsupported gradient/text range')
else:
colors = [tuple(map(int, stop[:3])) for stop in stops]
# Probe an empty padding column, inside corners and before text.
text_left = min(line['x'] for line in lines)
x = math.floor(min(text_left - 2, box['x'] + max(4, (text_left - box['x']) / 2)))
samples, deltas = [], []
for line in lines:
for y in range(math.ceil(line['y']), math.floor(line['y'] + line['height']) + 1):
if not (0 <= x < image.width and 0 <= y < image.height):
continue
if clip and not (clip['x'] + 1 <= x < clip['x'] + clip['width'] - 1 and clip['y'] + 1 <= y < clip['y'] + clip['height'] - 1):
continue
offset = (y + .5 - box['y']) / box['height']
if not 0 <= offset <= 1:
continue
pixel = image.getpixel((x, y))
modeled = tuple(a + (b - a) * offset for a, b in zip(*colors))
delta = max(abs(a - b) for a, b in zip(pixel, modeled))
deltas.append(delta)
samples.append(dict(x=x, y=y, background=pixel, contrast=contrast(foreground, pixel), modelDelta=delta))
if not samples or max(deltas) > 2:
entry.update(status='NOT VERIFIED', reason='Probe clipped, overpainted or disagrees with supported sRGB painting model', sampled=len(samples), maxModelDelta=max(deltas) if deltas else None)
else:
minimum = min(samples, key=lambda sample: sample['contrast'])
entry.update(status='PASS' if minimum['contrast'] >= threshold else 'REVIEW', method='PNG background beside actual DOM text lines',
ratio=minimum['contrast'], sampled=len(samples), maxModelDelta=max(deltas), weakest=minimum,
conservativeStopMinimum=min(contrast(foreground, color) for color in colors))
evidence.append(entry)
result = dict(method='DPR1 Chrome PNG; opaque foreground from computed CSS; padding-column gradient probes over DOM Range line bounds',
thresholds='Unrounded 4.5 normal / 3 large; no logo recoloring; no full WCAG certification',
results=evidence, statusCounts={status: sum(item['status'] == status for item in evidence) for status in ('PASS', 'REVIEW', 'NOT VERIFIED')})
(OUT / (sys.argv[2] if len(sys.argv) > 2 else 'contrast-pixels.json')).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(result['statusCounts']))
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>HM OrgChart · 직접 열기 예제</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="./assets/hm-theme.css"><link rel="stylesheet" href="./demo.css">
</head>
<body>
<main>
<h1>OrgChart Core · 직접 열기 예제</h1>
<p>가상 JSON을 Classic 번들로 실행합니다. file://에서 외부 JSON Fetch는 사용하지 않습니다.</p>
<div class="demo-controls" aria-label="Core 실행 예제">
<button id="reload" type="button">초기 JSON 재로드</button>
<button id="update" type="button">직원 이동·추가 반영</button>
<button id="destroy" type="button">인스턴스 정리</button>
<button id="recreate" type="button">새 인스턴스 생성</button>
</div>
<p id="status" role="status" aria-live="polite">준비 중</p><div id="chart-host"></div>
</main>
<script src="./assets/orgchart.browser.js"></script><script src="./fixtures.js"></script>
<script src="./app-classic.js"></script>
<script>startDemo(window.HMOrgChart, window.OrgChartFixtures);</script>
</body>
</html>
@@ -0,0 +1,116 @@
"""Extract business-management PNG colors into matching vector card templates.
Reads the reference only. No bitmap is embedded in the generated SVGs.
Usage: python create-business-card-assets.py <reference PNG path>
"""
import hashlib
import json
import re
import shutil
import sys
from pathlib import Path
import numpy as np
from PIL import Image
root = Path(__file__).resolve().parents[2]
reference = Path(sys.argv[1])
image = Image.open(reference).convert('RGBA')
pixels = np.asarray(image, dtype=float)
width, height = image.size
scale = np.array([width / 146, height / 68])
start = np.array([124.527, 13.2139]) * scale
end = np.array([91.4091, 70.3397]) * scale
vector = end - start
# Probe only the flat outer bands, away from rounded corners and inner face.
probes = ([(x, 2) for x in range(20, width - 20)]
+ [(3, y) for y in range(20, height - 15)]
+ [(width - 4, y) for y in range(20, height - 15)]
+ [(x, height - 3) for x in range(20, width - 20)])
coordinates = np.array(probes, dtype=float) + 0.5
times = np.clip((coordinates - start) @ vector / (vector @ vector), 0, 1)
values = np.array([pixels[y, x] for x, y in probes])
basis = np.zeros((len(times), 3))
first = times <= 0.72
basis[first, 0] = 1 - times[first] / 0.72
basis[first, 1] = times[first] / 0.72
basis[~first, 1] = (1 - times[~first]) / 0.28
basis[~first, 2] = (times[~first] - 0.72) / 0.28
# Test both interpolation representations rather than assuming raster alpha.
fits = []
for premultiplied in (False, True):
samples = values.copy()
if premultiplied:
samples[:, :3] *= samples[:, 3:] / 255
stops = np.linalg.lstsq(basis, samples, rcond=None)[0]
predicted = basis @ stops
if premultiplied:
predicted[:, :3] /= np.maximum(predicted[:, 3:], 1) / 255
stops[:, :3] /= np.maximum(stops[:, 3:], 1) / 255
error = float(np.abs(predicted - values).mean())
fits.append((error, premultiplied, np.clip(stops, 0, 255)))
error, premultiplied, stops = min(fits, key=lambda item: item[0])
# Use plateau medians for the clamped endpoints, reducing raster edge noise.
stops[0] = np.median(values[times == 0], axis=0)
stops[2] = np.median(values[times == 1], axis=0)
colors = ['#' + ''.join(f'{int(round(channel)):02X}' for channel in row[:3]) for row in stops]
opacities = [round(float(row[3] / 255), 2) for row in stops]
inner_top = pixels[9:35, 20:150].reshape(-1, 4)
inner_bottom = pixels[height - 13:height - 6, 20:150].reshape(-1, 4)
inner_colors = ['#' + ''.join(f'{int(round(channel)):02X}' for channel in row[:3])
for row in [np.median(inner_top, axis=0), np.median(inner_bottom, axis=0)]]
# Recover the inner face's gradient direction from its unclamped RGB plane.
# Colors alone are insufficient: retaining the template's gradient direction
# would leave a visible tone difference despite matching all endpoint colors.
inner_start_rgb = np.array([int(inner_colors[0][i:i + 2], 16) for i in (1, 3, 5)])
inner_end_rgb = np.array([int(inner_colors[1][i:i + 2], 16) for i in (1, 3, 5)])
delta = inner_start_rgb - inner_end_rgb
inner_points, inner_times = [], []
for y in range(12, height - 12, 2):
for x in range(20, min(220, width // 2 + 16), 2):
rgb = pixels[y, x, :3]
t = float((inner_start_rgb - rgb) @ delta / (delta @ delta))
if 0.06 < t < 0.94 and pixels[y, x, 3] == 255:
inner_points.append([(x + 0.5) / scale[0], (y + 0.5) / scale[1], 1])
inner_times.append(t)
plane = np.linalg.lstsq(np.array(inner_points), np.array(inner_times), rcond=None)[0]
gradient_vector = plane[:2] / (plane[:2] @ plane[:2])
inner_start = np.array([78.0741, (-plane[2] - plane[0] * 78.0741) / plane[1]])
inner_end = inner_start + gradient_vector
assets = root / 'packages/org-chart/src/assets/cards'
for suffix in ('', '-sm'):
template = (assets / f'경영기획팀{suffix}.svg').read_text(encoding='utf-8')
# Retain the supplied department geometry and its size-specific gradients.
for original, replacement in zip(['#DEE6F0', '#A8BBD1', '#577BA5'], colors):
template = template.replace(f'stop-color="{original}"', f'stop-color="{replacement}"')
template = template.replace(f'stop-color="{colors[2]}"', f'stop-color="{colors[2]}" stop-opacity="{opacities[2]}"')
template = template.replace('stop-color="white"', f'stop-color="{inner_colors[0]}"')
template = template.replace('stop-color="#E0E6F4"', f'stop-color="{inner_colors[1]}"')
template = template.replace('paint0_linear_', 'business_management_outer_').replace('paint1_linear_', 'business_management_inner_')
size_scale = np.array([1, 1]) if not suffix else np.array([108 / 146, 52 / 68])
p1, p2 = inner_start * size_scale, inner_end * size_scale
template = re.sub(r'(<linearGradient id="business_management_inner_[^"]+")[^>]+>',
lambda match: f'{match[1]} x1="{p1[0]:.4f}" y1="{p1[1]:.4f}" x2="{p2[0]:.4f}" y2="{p2[1]:.4f}" gradientUnits="userSpaceOnUse">', template)
template = template.replace('offset="0.97"', 'offset="1"')
(assets / f'사업관리팀{suffix}.svg').write_text(template, encoding='utf-8')
output = root / 'test-results/org-chart-card-design'
output.mkdir(parents=True, exist_ok=True)
shutil.copyfile(reference, output / 'business-management-reference.png')
evidence = {
'reference': str(reference), 'sha256': hashlib.sha256(reference.read_bytes()).hexdigest(),
'referenceSize': [width, height], 'outerStops': colors, 'outerOpacities': opacities,
'innerStops': inner_colors, 'outerStopOffsets': [0, 0.72, 1],
'innerGradientStart': inner_start.tolist(), 'innerGradientEnd': inner_end.tolist(),
'innerSampleCount': len(inner_points),
'sampleCount': len(probes), 'fitMeanAbsoluteRGBAError': error,
'premultipliedFit': premultiplied,
'method': 'Outer-band RGBA samples fitted to the supplied department gradient geometry; endpoint plateau medians and inner-face medians. Inner gradient direction recovered from unclamped RGB plane. Reference PNG is preserved; geometry reuses department SVG paths.',
}
(output / 'business-management-colors.json').write_text(json.dumps(evidence, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(evidence, ensure_ascii=False, indent=2))
+15
View File
@@ -0,0 +1,15 @@
// One-time fixture authoring helper; normal example builds read the checked-in fixtures.
import { readFile, writeFile } from 'node:fs/promises';
const document = await readFile(new URL('../../docs/org-chart/data-contract.md', import.meta.url), 'utf8');
const snippets = [...document.matchAll(/```json\r?\n([\s\S]*?)\r?\n```/g)].map(match => JSON.parse(match[1]));
const initial = snippets.find(value => Array.isArray(value.organizations));
if (!initial) throw new Error('No documented fictitious dataset');
initial.schemaVersion = '0.1-draft';
initial.presentation.description = '가상 데이터 · 원본 집계와 업무상 총원은 구분됩니다.';
const changed = structuredClone(initial);
changed.metadata.dataVersion = 'mock-2';
changed.organizations[1].name = '가상 전략기획팀';
changed.employees.find(person => person.id === 'emp-c').organizationId = 'team-b';
changed.employees.push({ id: 'emp-e', organizationId: 'team-b', affiliationId: 'aff-a', name: '가상직원 마', position: '책임', jobTitle: null, profileImage: null, workStatus: 'active' });
for (const [file, value] of [['data-v1.json', initial], ['data-v2.json', changed]])
await writeFile(new URL(file, import.meta.url), JSON.stringify(value, null, 2) + '\n');
+56
View File
@@ -0,0 +1,56 @@
import { writeFile } from 'node:fs/promises';
// Organization names were observed on Workdesk. Membership, employees and
// every count below are synthetic, not an export of production HR data.
const groups = [
['business-management', '사업관리', 'office', []],
['business-planning', '경영기획', 'office', ['경영기획 셀', '사업관리 셀']],
['talent-growth', '인재성장', 'office', ['HR기획 셀', '자산관리 셀']],
['integrated-system', '통합시스템 Div', 'div', ['IS 1팀', 'IS 2팀', 'IS 3팀']],
['design-planning', '디자인기획', 'office', ['UX 셀', '영상 셀', '편집 셀']],
['solution-sales', '솔루션영업', 'office', ['S/W 셀']],
];
const affiliations = [
['hanmac', '(주)한맥기술'], ['saman', '(주)삼안'], ['jangheon', '(주)장헌'],
['jangheon-industry', '장헌산업'], ['ptc', 'PTC'], ['halla', '한라산업개발'], ['baron', '바론컨설턴트'],
].map(([id, name]) => ({ id, name }));
const organizations = [{ id: 'general-planning', parentId: null, name: '총괄기획실', type: 'root', leaderId: 'mock-leader-00', displayOrder: 0 }];
// Invented display names only; never sourced from a Workdesk employee capture.
const names = [
'김솔', '이도윤', '박하준', '정지우', '최예린', '한준서', '오수아',
'강민서', '윤시우', '서다은', '배지안', '임서준', '송하린', '조유찬',
'신도하', '문예은', '권지호', '안채원', '유시온', '홍서아', '고은우',
'남하윤', '백도현', '노유나', '장태오', '양서진', '진서율', '구민재',
'전하은', '심이준', '차소율', '우지율', '손예준', '함다온', '허지유',
'황서우', '성유빈', '곽지후', '류다인', '하주원', '이봄',
];
const employees = [{ id: 'mock-leader-00', organizationId: 'general-planning', affiliationId: 'hanmac', name: names[0], position: '경영진', jobTitle: '실장', profileImage: null, workStatus: 'active' }];
let person = 1;
for (const [index, [id, name, type, cells]] of groups.entries()) {
const leaderId = `mock-leader-${String(index + 1).padStart(2, '0')}`;
organizations.push({ id, parentId: 'general-planning', name, type, leaderId, displayOrder: index });
employees.push({ id: leaderId, organizationId: id, affiliationId: ['hanmac', 'saman', 'baron'][index % 3], name: names[employees.length], position: '수석', jobTitle: type === 'div' ? '디비전장' : '팀장', profileImage: null, workStatus: 'active' });
const employeeOrganizations = cells.length ? cells.map((cell, cellIndex) => {
const childId = `${id}-${cellIndex + 1}`;
organizations.push({ id: childId, parentId: id, name: cell, type: cell.includes('팀') ? 'team' : 'cell', leaderId: null, displayOrder: cellIndex });
return childId;
}) : [id];
for (const childId of employeeOrganizations) for (let member = 0; member < (cells.length ? 3 : 1); member++) {
const employeeId = `mock-employee-${String(person).padStart(2, '0')}`;
const isTeamLeader = childId.startsWith('integrated-system-') && member === 0;
employees.push({ id: employeeId, organizationId: childId, affiliationId: ['hanmac', 'saman', 'ptc', 'baron'][person % 4], name: names[employees.length], position: ['책임', '선임', '연구'][member % 3], jobTitle: isTeamLeader ? '팀장' : null, profileImage: null, workStatus: person === 3 ? 'dispatched' : person === 25 ? 'onLeave' : 'active' });
if (isTeamLeader) organizations.find(item => item.id === childId).leaderId = employeeId;
person++;
}
}
const fixture = {
schemaVersion: '0.1-draft', organizations, employees, affiliations, relations: [],
presentation: {
title: '총괄기획실 조직도', description: '실제 조직명 · 가상 직원 정보 및 인원 통계 · 운영 인원수가 아닙니다.', hubText: '협업 · 소통 허브',
groups: { showEmployees: true, showSummary: true, showDispatched: true, showOnLeave: true, showAffiliations: true },
organizationOrder: ['general-planning', ...groups.map(([id]) => id)],
},
metadata: { asOfDate: '2026-10-12', dataVersion: 'p1-virtual-employees-v1', updateNote: '운영 HR 정보와 무관한 가상 구성' },
};
await writeFile(new URL('./data-p1.json', import.meta.url), `${JSON.stringify(fixture, null, 2)}\n`);
console.log(`P1 fixture: ${organizations.length} organizations / ${employees.length} synthetic employees`);
+618
View File
@@ -0,0 +1,618 @@
{
"schemaVersion": "0.1-draft",
"organizations": [
{
"id": "general-planning",
"parentId": null,
"name": "총괄기획실",
"type": "root",
"leaderId": "mock-leader-00",
"displayOrder": 0
},
{
"id": "business-management",
"parentId": "general-planning",
"name": "사업관리",
"type": "office",
"leaderId": "mock-leader-01",
"displayOrder": 0
},
{
"id": "business-planning",
"parentId": "general-planning",
"name": "경영기획",
"type": "office",
"leaderId": "mock-leader-02",
"displayOrder": 1
},
{
"id": "business-planning-1",
"parentId": "business-planning",
"name": "경영기획 셀",
"type": "cell",
"leaderId": null,
"displayOrder": 0
},
{
"id": "business-planning-2",
"parentId": "business-planning",
"name": "사업관리 셀",
"type": "cell",
"leaderId": null,
"displayOrder": 1
},
{
"id": "talent-growth",
"parentId": "general-planning",
"name": "인재성장",
"type": "office",
"leaderId": "mock-leader-03",
"displayOrder": 2
},
{
"id": "talent-growth-1",
"parentId": "talent-growth",
"name": "HR기획 셀",
"type": "cell",
"leaderId": null,
"displayOrder": 0
},
{
"id": "talent-growth-2",
"parentId": "talent-growth",
"name": "자산관리 셀",
"type": "cell",
"leaderId": null,
"displayOrder": 1
},
{
"id": "integrated-system",
"parentId": "general-planning",
"name": "통합시스템 Div",
"type": "div",
"leaderId": "mock-leader-04",
"displayOrder": 3
},
{
"id": "integrated-system-1",
"parentId": "integrated-system",
"name": "IS 1팀",
"type": "team",
"leaderId": "mock-employee-14",
"displayOrder": 0
},
{
"id": "integrated-system-2",
"parentId": "integrated-system",
"name": "IS 2팀",
"type": "team",
"leaderId": "mock-employee-17",
"displayOrder": 1
},
{
"id": "integrated-system-3",
"parentId": "integrated-system",
"name": "IS 3팀",
"type": "team",
"leaderId": "mock-employee-20",
"displayOrder": 2
},
{
"id": "design-planning",
"parentId": "general-planning",
"name": "디자인기획",
"type": "office",
"leaderId": "mock-leader-05",
"displayOrder": 4
},
{
"id": "design-planning-1",
"parentId": "design-planning",
"name": "UX 셀",
"type": "cell",
"leaderId": null,
"displayOrder": 0
},
{
"id": "design-planning-2",
"parentId": "design-planning",
"name": "영상 셀",
"type": "cell",
"leaderId": null,
"displayOrder": 1
},
{
"id": "design-planning-3",
"parentId": "design-planning",
"name": "편집 셀",
"type": "cell",
"leaderId": null,
"displayOrder": 2
},
{
"id": "solution-sales",
"parentId": "general-planning",
"name": "솔루션영업",
"type": "office",
"leaderId": "mock-leader-06",
"displayOrder": 5
},
{
"id": "solution-sales-1",
"parentId": "solution-sales",
"name": "S/W 셀",
"type": "cell",
"leaderId": null,
"displayOrder": 0
}
],
"employees": [
{
"id": "mock-leader-00",
"organizationId": "general-planning",
"affiliationId": "hanmac",
"name": "김솔",
"position": "경영진",
"jobTitle": "실장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-leader-01",
"organizationId": "business-management",
"affiliationId": "hanmac",
"name": "이도윤",
"position": "수석",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-01",
"organizationId": "business-management",
"affiliationId": "saman",
"name": "박하준",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-leader-02",
"organizationId": "business-planning",
"affiliationId": "saman",
"name": "정지우",
"position": "수석",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-02",
"organizationId": "business-planning-1",
"affiliationId": "ptc",
"name": "최예린",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-03",
"organizationId": "business-planning-1",
"affiliationId": "baron",
"name": "한준서",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "dispatched"
},
{
"id": "mock-employee-04",
"organizationId": "business-planning-1",
"affiliationId": "hanmac",
"name": "오수아",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-05",
"organizationId": "business-planning-2",
"affiliationId": "saman",
"name": "강민서",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-06",
"organizationId": "business-planning-2",
"affiliationId": "ptc",
"name": "윤시우",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-07",
"organizationId": "business-planning-2",
"affiliationId": "baron",
"name": "서다은",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-leader-03",
"organizationId": "talent-growth",
"affiliationId": "baron",
"name": "배지안",
"position": "수석",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-08",
"organizationId": "talent-growth-1",
"affiliationId": "hanmac",
"name": "임서준",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-09",
"organizationId": "talent-growth-1",
"affiliationId": "saman",
"name": "송하린",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-10",
"organizationId": "talent-growth-1",
"affiliationId": "ptc",
"name": "조유찬",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-11",
"organizationId": "talent-growth-2",
"affiliationId": "baron",
"name": "신도하",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-12",
"organizationId": "talent-growth-2",
"affiliationId": "hanmac",
"name": "문예은",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-13",
"organizationId": "talent-growth-2",
"affiliationId": "saman",
"name": "권지호",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-leader-04",
"organizationId": "integrated-system",
"affiliationId": "hanmac",
"name": "안채원",
"position": "수석",
"jobTitle": "디비전장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-14",
"organizationId": "integrated-system-1",
"affiliationId": "ptc",
"name": "유시온",
"position": "책임",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-15",
"organizationId": "integrated-system-1",
"affiliationId": "baron",
"name": "홍서아",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-16",
"organizationId": "integrated-system-1",
"affiliationId": "hanmac",
"name": "고은우",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-17",
"organizationId": "integrated-system-2",
"affiliationId": "saman",
"name": "남하윤",
"position": "책임",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-18",
"organizationId": "integrated-system-2",
"affiliationId": "ptc",
"name": "백도현",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-19",
"organizationId": "integrated-system-2",
"affiliationId": "baron",
"name": "노유나",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-20",
"organizationId": "integrated-system-3",
"affiliationId": "hanmac",
"name": "장태오",
"position": "책임",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-21",
"organizationId": "integrated-system-3",
"affiliationId": "saman",
"name": "양서진",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-22",
"organizationId": "integrated-system-3",
"affiliationId": "ptc",
"name": "진서율",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-leader-05",
"organizationId": "design-planning",
"affiliationId": "saman",
"name": "구민재",
"position": "수석",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-23",
"organizationId": "design-planning-1",
"affiliationId": "baron",
"name": "전하은",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-24",
"organizationId": "design-planning-1",
"affiliationId": "hanmac",
"name": "심이준",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-25",
"organizationId": "design-planning-1",
"affiliationId": "saman",
"name": "차소율",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "onLeave"
},
{
"id": "mock-employee-26",
"organizationId": "design-planning-2",
"affiliationId": "ptc",
"name": "우지율",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-27",
"organizationId": "design-planning-2",
"affiliationId": "baron",
"name": "손예준",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-28",
"organizationId": "design-planning-2",
"affiliationId": "hanmac",
"name": "함다온",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-29",
"organizationId": "design-planning-3",
"affiliationId": "saman",
"name": "허지유",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-30",
"organizationId": "design-planning-3",
"affiliationId": "ptc",
"name": "황서우",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-31",
"organizationId": "design-planning-3",
"affiliationId": "baron",
"name": "성유빈",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-leader-06",
"organizationId": "solution-sales",
"affiliationId": "baron",
"name": "곽지후",
"position": "수석",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-32",
"organizationId": "solution-sales-1",
"affiliationId": "hanmac",
"name": "류다인",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-33",
"organizationId": "solution-sales-1",
"affiliationId": "saman",
"name": "하주원",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
},
{
"id": "mock-employee-34",
"organizationId": "solution-sales-1",
"affiliationId": "ptc",
"name": "이봄",
"position": "연구",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
}
],
"affiliations": [
{
"id": "hanmac",
"name": "(주)한맥기술"
},
{
"id": "saman",
"name": "(주)삼안"
},
{
"id": "jangheon",
"name": "(주)장헌"
},
{
"id": "jangheon-industry",
"name": "장헌산업"
},
{
"id": "ptc",
"name": "PTC"
},
{
"id": "halla",
"name": "한라산업개발"
},
{
"id": "baron",
"name": "바론컨설턴트"
}
],
"relations": [],
"presentation": {
"title": "총괄기획실 조직도",
"description": "실제 조직명 · 가상 직원 정보 및 인원 통계 · 운영 인원수가 아닙니다.",
"hubText": "협업 · 소통 허브",
"groups": {
"showEmployees": true,
"showSummary": true,
"showDispatched": true,
"showOnLeave": true,
"showAffiliations": true
},
"organizationOrder": [
"general-planning",
"business-management",
"business-planning",
"talent-growth",
"integrated-system",
"design-planning",
"solution-sales"
]
},
"metadata": {
"asOfDate": "2026-10-12",
"dataVersion": "p1-virtual-employees-v1",
"updateNote": "운영 HR 정보와 무관한 가상 구성"
}
}
+113
View File
@@ -0,0 +1,113 @@
{
"organizations": [
{
"id": "org-root",
"parentId": null,
"name": "가상 사업본부",
"type": "root",
"leaderId": "emp-a",
"displayOrder": 0
},
{
"id": "team-a",
"parentId": "org-root",
"name": "가상 기획팀",
"type": "team",
"leaderId": "emp-b",
"displayOrder": 1
},
{
"id": "team-b",
"parentId": "org-root",
"name": "가상 운영팀",
"type": "team",
"leaderId": null,
"displayOrder": 2
}
],
"employees": [
{
"id": "emp-a",
"organizationId": "org-root",
"affiliationId": "aff-a",
"name": "가상직원 가",
"position": "대표",
"jobTitle": "본부장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "emp-b",
"organizationId": "team-a",
"affiliationId": "aff-a",
"name": "가상직원 나",
"position": "책임",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "emp-c",
"organizationId": "team-a",
"affiliationId": "aff-b",
"name": "가상직원 다",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "dispatched"
},
{
"id": "emp-d",
"organizationId": "team-b",
"affiliationId": "aff-b",
"name": "가상직원 라",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "onLeave"
}
],
"affiliations": [
{
"id": "aff-a",
"name": "가상 관계사 A"
},
{
"id": "aff-b",
"name": "가상 관계사 B"
}
],
"relations": [
{
"id": "rel-a",
"sourceOrganizationId": "team-a",
"targetOrganizationId": "team-b",
"type": "cooperation"
}
],
"presentation": {
"title": "가상 조직도",
"description": "가상 데이터 · 원본 집계와 업무상 총원은 구분됩니다.",
"hubText": "협업 중심",
"groups": {
"showEmployees": true,
"showSummary": true,
"showDispatched": true,
"showOnLeave": true,
"showAffiliations": true
},
"organizationOrder": [
"org-root",
"team-a",
"team-b"
]
},
"metadata": {
"schemaVersion": "0.1-draft",
"asOfDate": "2026-10-12",
"dataVersion": "mock-1",
"updatedAt": null,
"updateNote": "초안 예시"
},
"schemaVersion": "0.1-draft"
}
+123
View File
@@ -0,0 +1,123 @@
{
"organizations": [
{
"id": "org-root",
"parentId": null,
"name": "가상 사업본부",
"type": "root",
"leaderId": "emp-a",
"displayOrder": 0
},
{
"id": "team-a",
"parentId": "org-root",
"name": "가상 전략기획팀",
"type": "team",
"leaderId": "emp-b",
"displayOrder": 1
},
{
"id": "team-b",
"parentId": "org-root",
"name": "가상 운영팀",
"type": "team",
"leaderId": null,
"displayOrder": 2
}
],
"employees": [
{
"id": "emp-a",
"organizationId": "org-root",
"affiliationId": "aff-a",
"name": "가상직원 가",
"position": "대표",
"jobTitle": "본부장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "emp-b",
"organizationId": "team-a",
"affiliationId": "aff-a",
"name": "가상직원 나",
"position": "책임",
"jobTitle": "팀장",
"profileImage": null,
"workStatus": "active"
},
{
"id": "emp-c",
"organizationId": "team-b",
"affiliationId": "aff-b",
"name": "가상직원 다",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "dispatched"
},
{
"id": "emp-d",
"organizationId": "team-b",
"affiliationId": "aff-b",
"name": "가상직원 라",
"position": "선임",
"jobTitle": null,
"profileImage": null,
"workStatus": "onLeave"
},
{
"id": "emp-e",
"organizationId": "team-b",
"affiliationId": "aff-a",
"name": "가상직원 마",
"position": "책임",
"jobTitle": null,
"profileImage": null,
"workStatus": "active"
}
],
"affiliations": [
{
"id": "aff-a",
"name": "가상 관계사 A"
},
{
"id": "aff-b",
"name": "가상 관계사 B"
}
],
"relations": [
{
"id": "rel-a",
"sourceOrganizationId": "team-a",
"targetOrganizationId": "team-b",
"type": "cooperation"
}
],
"presentation": {
"title": "가상 조직도",
"description": "가상 데이터 · 원본 집계와 업무상 총원은 구분됩니다.",
"hubText": "협업 중심",
"groups": {
"showEmployees": true,
"showSummary": true,
"showDispatched": true,
"showOnLeave": true,
"showAffiliations": true
},
"organizationOrder": [
"org-root",
"team-a",
"team-b"
]
},
"metadata": {
"schemaVersion": "0.1-draft",
"asOfDate": "2026-10-12",
"dataVersion": "mock-2",
"updatedAt": null,
"updateNote": "초안 예시"
},
"schemaVersion": "0.1-draft"
}
+8
View File
@@ -0,0 +1,8 @@
body { margin: 0; font-family: var(--hm-font-family-base); color: var(--hm-sem-fg-primary); background: var(--hm-sem-bg-content); }
main { padding: var(--hm-card-padding); }
h1 { margin: 0; font-size: var(--hm-card-title-font-size); font-weight: var(--hm-card-title-font-weight); }
main > p { color: var(--hm-sem-fg-secondary); }
.demo-controls { display: flex; flex-wrap: wrap; gap: 8px; }
.demo-controls button { font: inherit; color: var(--hm-sem-fg-primary); background: var(--hm-sem-bg-surface); border: 1px solid var(--hm-sem-stroke-default); border-radius: var(--hm-radius-control-md); padding: 8px 12px; cursor: pointer; }
.demo-controls button:focus-visible { outline: 2px solid var(--hm-sem-stroke-input-focus); outline-offset: 2px; }
#chart-host { min-width: 0; }
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>HM OrgChart · Vanilla JSON 예제</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="./assets/hm-theme.css"><link rel="stylesheet" href="./demo.css">
</head>
<body>
<main>
<h1>OrgChart Core · JSON 예제</h1>
<p>가상 조직·직원 데이터입니다. 표시된 원본 직원 수는 업무상 재직 총원과 구분됩니다.</p>
<div class="demo-controls" aria-label="Core 실행 예제">
<button id="reload" type="button">JSON 재로드</button>
<button id="update" type="button">직원 이동·추가 반영</button>
<button id="destroy" type="button">인스턴스 정리</button>
<button id="recreate" type="button">새 인스턴스 생성</button>
</div>
<p id="status" role="status" aria-live="polite">준비 중</p>
<div id="chart-host"></div>
</main>
<script type="module">
import * as HMOrgChart from './assets/index.js';
import { startDemo } from './app.js';
startDemo(HMOrgChart);
</script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
export async function startP1(api, inlineFixture) {
const data = inlineFixture ?? await new api.JsonOrgChartDataSource('./data-p1.json').load();
const stylesheetUrls = {
engine: new URL('./assets/engine.css', location.href).href,
core: new URL('./assets/style.css', location.href).href,
};
// ?mode=readOnly shows the view-only Workdesk composition; the default stays interactive.
const displayMode = new URLSearchParams(location.search).get('mode') === 'readOnly' ? 'readOnly' : 'interactive';
document.documentElement.dataset.orgchartMode = displayMode;
const chart = api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrls, displayMode });
// Development inspection seam: public Core API only. No engine internals.
window.OrgChartP1 = { api, data, chart, stylesheetUrls, setData: next => chart.update(next) };
window.addEventListener('pagehide', () => chart.destroy(), { once: true });
await chart.mount();
}
+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">
<title>HM OrgChart P1 · 직접 열기</title><link rel="icon" href="data:,">
<link rel="stylesheet" href="./p1.css">
</head>
<body>
<main><div id="chart-host"></div></main>
<script src="./assets/orgchart.browser.js"></script><script src="./p1-fixture.js"></script>
<script src="./p1-app-classic.js"></script><script>startP1(window.HMOrgChart, window.OrgChartP1Fixture);</script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
/* Reuse the existing local variable font; only OrgChart selects this family. */
@font-face {
font-family: 'Noto Sans KR';
src: url('./assets/fonts/NotoSansKR-variable.ttf') format('truetype');
font-style: normal;
font-weight: 100 900;
font-display: swap;
}
body { margin: 0; background: #ffffff; }
main { padding: 16px; }
#chart-host { min-width: 0; }
html[data-orgchart-mode="readOnly"] body { background: #F5F8FB; }
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>HM OrgChart P1 · Full Composition</title><link rel="icon" href="data:,">
<link rel="stylesheet" href="./p1.css">
</head>
<body>
<main><div id="chart-host"></div></main>
<script type="module">
import * as api from './assets/index.js';
import { startP1 } from './p1-app.js';
startP1(api);
</script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const example = fileURLToPath(new URL('./', import.meta.url));
const root = path.resolve(example, '../..');
const output = path.join(example, 'dist');
await mkdir(path.join(output, 'assets'), { recursive: true });
// The host's theme is generated from the existing public CSS entry; no maintained theme fork.
async function flatten(file, seen = new Set()) {
const full = path.resolve(file);
if (seen.has(full)) throw new Error('Theme CSS import cycle');
const stack = new Set(seen).add(full);
const source = await readFile(full, 'utf8');
const matches = [...source.matchAll(/@import\s+["']([^"']+)["'];/g)];
let result = '', cursor = 0;
for (const match of matches) {
result += source.slice(cursor, match.index) + await flatten(path.resolve(path.dirname(full), match[1]), stack);
cursor = match.index + match[0].length;
}
return result + source.slice(cursor);
}
const theme = (await flatten(path.join(root, 'packages/ui/src/theme.css')))
.replace('/fonts/NotoSansKR-variable.ttf', './fonts/NotoSansKR-variable.ttf');
await writeFile(path.join(output, 'assets/hm-theme.css'), theme);
await cp(path.join(root, 'public/fonts'), path.join(output, 'assets/fonts'), { recursive: true });
for (const file of ['index.js', 'orgchart.browser.js', 'style.css', 'engine.css'])
await cp(path.join(root, 'packages/org-chart/dist', file), path.join(output, 'assets', file));
await cp(path.join(root, 'packages/org-chart/dist/licenses'), path.join(output, 'assets/licenses'), { recursive: true });
for (const file of ['index.html', 'classic.html', 'app.js', 'demo.css', 'data-v1.json', 'data-v2.json', 'data-p1.json', 'p1.html', 'p1-classic.html', 'p1-app.js', 'p1.css'])
await cp(path.join(example, file), path.join(output, file));
const initial = JSON.parse(await readFile(path.join(example, 'data-v1.json'), 'utf8'));
const changed = JSON.parse(await readFile(path.join(example, 'data-v2.json'), 'utf8'));
await writeFile(path.join(output, 'fixtures.js'), `window.OrgChartFixtures = ${JSON.stringify({initial, changed})};\n`);
const app = await readFile(path.join(example, 'app.js'), 'utf8');
await writeFile(path.join(output, 'app-classic.js'), app.replace('export function startDemo', 'function startDemo'));
const p1 = JSON.parse(await readFile(path.join(example, 'data-p1.json'), 'utf8'));
await writeFile(path.join(output, 'p1-fixture.js'), `window.OrgChartP1Fixture = ${JSON.stringify(p1)};\n`);
const p1App = await readFile(path.join(example, 'p1-app.js'), 'utf8');
await writeFile(path.join(output, 'p1-app-classic.js'), p1App.replace('export async function startP1', 'async function startP1'));
console.log(`OrgChart HTML example: ${output}`);
+18
View File
@@ -0,0 +1,18 @@
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const base = fileURLToPath(new URL('./dist/', import.meta.url));
const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.css': 'text/css; charset=utf-8', '.ttf': 'font/ttf' };
const port = Number(process.env.ORGCHART_PORT || 4178);
http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
const file = path.resolve(base, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(base, 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.setHeader('Cache-Control', 'no-store'); response.end(data);
} catch { response.writeHead(404).end(); }
}).listen(port, '127.0.0.1', () => console.log(`OrgChart example: http://127.0.0.1:${port}`));
+204
View File
@@ -0,0 +1,204 @@
import http from 'node:http';
import path from 'node:path';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
// Stage 2 only: typography, data placement, overflow and accessible tooltip.
// Do not repeat Stage 1's gradient/data/Summary/engine suites.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-card-content');
await mkdir(output, { recursive: true });
const { chromium } = await import(process.env.ORGCHART_PLAYWRIGHT_MODULE ? pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href : 'playwright');
const results = [], errors = [], measurements = [], servers = [];
const check = (value, name, detail) => results.push({ name, status: value ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf' };
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/profile-fixture.svg') {
response.setHeader('Content-Type', 'image/svg+xml');
return response.end('<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><circle cx="20" cy="20" r="20" fill="#45639f"/></svg>');
}
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(directory, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
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)); servers.push(server);
return `http://127.0.0.1:${server.address().port}`;
}
async function ready(page) {
await page.waitForFunction(() => {
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
return shadow?.querySelector('[data-card-asset] .hm-orgchart-person-name[data-name-overflow]')
&& [...shadow.querySelectorAll('link')].every(link => !!link.sheet);
});
await page.evaluate(async () => {
await document.fonts.ready;
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
await Promise.all([...shadow.querySelectorAll('img')].map(img => img.decode().catch(() => {})));
});
}
async function measure(page) {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
const rect = element => { const b = element.getBoundingClientRect(); return { left: b.left, right: b.right, top: b.top, bottom: b.bottom, width: b.width, height: b.height }; };
const typography = element => {
const style = getComputedStyle(element);
return Object.fromEntries(['color', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'margin', 'padding', 'whiteSpace', 'textOverflow', 'fontFamily'].map(key => [key, style[key]]));
};
const cards = [...shadow.querySelectorAll('[data-card-asset]')].map(card => {
const role = card.querySelector('.hm-orgchart-person-role'), name = card.querySelector('.hm-orgchart-person-name');
const avatar = card.querySelector('.hm-orgchart-avatar'), logo = card.querySelector('.hm-orgchart-affiliation-mark');
const bounds = rect(card), roleBounds = rect(role), nameBounds = rect(name), issues = [];
const overlap = (a, 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 contained = b => b.left >= bounds.left - 1 && b.right <= bounds.right + 1 && b.top >= bounds.top - 1 && b.bottom <= bounds.bottom + 1;
for (const [label, element] of [['role', role], ['name', name], ['avatar', avatar], ['logo', logo]]) if (!contained(rect(element))) issues.push(`${label} outside card`);
for (const element of [role, name]) {
// A clipped name's DOM Range intentionally retains the full text width;
// visible text is intersected with its clipping box for collision checks.
const range = document.createRange(); range.selectNodeContents(element);
for (const r of range.getClientRects()) {
const box = rect(element);
const visible = { left: Math.max(r.left, box.left), right: Math.min(r.right, box.right), top: Math.max(r.top, box.top), bottom: Math.min(r.bottom, box.bottom) };
if (overlap(visible, rect(avatar))) issues.push(`${element.className}: avatar overlap`);
if (overlap(visible, rect(logo))) issues.push(`${element.className}: logo overlap`);
}
}
const badge = card.querySelector('.hm-orgchart-job-title');
if (badge) {
if (!contained(rect(badge))) issues.push('badge outside card');
if (overlap(rect(badge), rect(avatar)) || overlap(rect(badge), nameBounds)) issues.push('badge overlap');
}
return { id: card.dataset.employeeId, size: card.dataset.cardAssetSize, name: name.textContent, role: typography(role), person: typography(name),
width: bounds.width, height: bounds.height, avatarWidth: rect(avatar).width, avatarHeight: rect(avatar).height,
textGap: nameBounds.top - roleBounds.bottom, cssGap: getComputedStyle(card.querySelector('.hm-orgchart-person-text')).gap,
overflow: name.dataset.nameOverflow, focusable: name.getAttribute('tabindex'), description: !!name.getAttribute('aria-describedby'),
nameWidth: name.clientWidth, nameScrollWidth: name.scrollWidth, issues,
imageLoaded: !!avatar.querySelector('img')?.naturalWidth,
logoTransparent: getComputedStyle(logo.querySelector('img')).backgroundColor === 'rgba(0, 0, 0, 0)',
fallbackHidden: getComputedStyle(logo.querySelector('.hm-orgchart-affiliation-fallback')).visibility === 'hidden' };
});
const viewport = shadow.querySelector('.hm-orgchart-viewport');
return { cards, pageOverflow: document.documentElement.scrollWidth > innerWidth + 1,
extents: { document: [document.documentElement.scrollWidth, document.documentElement.scrollHeight, document.documentElement.clientWidth, document.documentElement.clientHeight],
viewport: viewport ? [viewport.scrollWidth, viewport.scrollHeight, viewport.clientWidth, viewport.clientHeight] : null },
orphanTooltips: [...shadow.querySelectorAll('[role=tooltip]')].filter(tip => !shadow.querySelector(`[aria-describedby="${tip.id}"]`)).length };
});
}
function verifyTypography(value, label) {
for (const size of ['representative', 'leader', 'employee']) {
const cards = value.cards.filter(card => card.size === size), small = size === 'employee';
check(cards.length > 0 && cards.every(card => card.role.color === (size === 'representative' ? 'rgb(34, 67, 135)' : 'rgb(121, 121, 121)')
&& card.role.fontSize === (small ? '15px' : '20px') && card.role.fontWeight === '400' && card.role.lineHeight === (small ? '18px' : '22px')
&& card.role.letterSpacing === (small ? '-0.6px' : '-0.8px')), `${label}/${size}: position exact computed typography`);
check(cards.every(card => card.person.color === 'rgb(7, 29, 58)' && card.person.fontSize === (small ? '20px' : '24px') && card.person.fontWeight === '600'
&& card.person.lineHeight === (small ? '24px' : '30px') && card.person.letterSpacing === (size === 'leader' ? '-0.96px' : '-0.8px')), `${label}/${size}: name exact computed typography`);
check(cards.every(card => card.cssGap === '0px' && card.textGap === 0 && card.role.margin === '0px' && card.role.padding === '0px'
&& card.person.margin === '0px' && card.person.padding === '0px'), `${label}/${size}: zero gap/margins/padding between two text rows`);
check(cards.every(card => card.width === (small ? 108 : 146) && card.height === (size === 'representative' ? 74 : small ? 52 : 68)
&& card.avatarWidth === (size === 'representative' ? 36 : small ? 24 : 32) && card.avatarHeight === card.avatarWidth), `${label}/${size}: original card/profile dimensions retained`);
}
check(value.cards.every(card => card.issues.length === 0), `${label}: content collisions/bounds`, value.cards.filter(card => card.issues.length));
check(value.cards.every(card => card.logoTransparent && card.fallbackHidden), `${label}: transparent original logos/fallback retained`);
check(!value.pageOverflow && value.orphanTooltips === 0, `${label}: no new page overflow/orphan tooltip`);
}
async function tooltipChecks(page, label) {
const names = page.locator('[data-card-asset] .hm-orgchart-person-name[data-name-overflow=true]');
for (const size of ['representative', 'leader', 'employee']) {
const name = page.locator(`[data-card-asset-size="${size}"] .hm-orgchart-person-name[data-name-overflow=true]`).first();
await name.scrollIntoViewIfNeeded();
await name.hover();
const description = await name.getAttribute('aria-describedby'), tip = page.locator(`#${description}`);
check(await tip.isVisible() && await tip.innerText() === await name.innerText(), `${label}/${size}: hover shows complete original name`);
await tip.hover();
check(await tip.isVisible(), `${label}/${size}: tooltip hoverable`);
await page.mouse.move(0, 0);
await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot.querySelectorAll('[role=tooltip]')].every(tip => tip.hidden));
// Tab through the real keyboard order from this explicit preceding control.
await name.evaluate(element => { const previous = document.createElement('button'); previous.textContent = 'keyboard test start'; element.before(previous); previous.focus(); });
await page.keyboard.press('Tab');
check(await name.evaluate(element => element.getRootNode().activeElement === element) && await tip.isVisible(), `${label}/${size}: Tab focus reveals full name`);
check((await name.ariaSnapshot()).includes(await name.innerText()) && await name.getAttribute('aria-describedby') === await tip.getAttribute('id'), `${label}/${size}: full text retained in Chromium accessibility representation/description`);
await page.keyboard.press('Escape');
check(!(await tip.isVisible()) && await name.evaluate(element => element.getRootNode().activeElement === element), `${label}/${size}: Escape dismisses, focus retained`);
const tipBox = await tip.evaluate(element => { element.hidden = false; const b = element.getBoundingClientRect(); element.hidden = true; return { left: b.left, right: b.right, top: b.top, bottom: b.bottom }; });
const screen = page.viewportSize();
check(tipBox.left >= 0 && tipBox.right <= screen.width + 1 && tipBox.top >= 0 && tipBox.bottom <= screen.height + 1, `${label}/${size}: tooltip inside browser viewport`);
await name.evaluate(element => element.previousElementSibling.remove());
}
check(await names.count() === 3, `${label}: only the three explicit long-name cases are focusable`);
}
let browser;
try {
const origins = await Promise.all(['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist'].map(directory => serve(path.join(root, directory))));
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce' });
const routes = [['department-colors', `${origins[0]}/iframe.html?id=orgchart--department-colors&viewMode=story`],
['full-composition', `${origins[0]}/iframe.html?id=orgchart--full-composition&viewMode=story`], ['html', `${origins[1]}/p1.html`], ['react', `${origins[2]}/p1.html`]];
for (const [label, url] of routes) {
const page = await context.newPage();
page.on('pageerror', error => errors.push({ label, message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ label, message: message.text() }); });
await page.goto(url); await ready(page);
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1000 }); await ready(page);
const value = await measure(page); measurements.push({ label, width, ...value }); verifyTypography(value, `${label}/${width}`);
check(value.cards.every(card => /^[가-힣]{2,3}$/.test(card.name) && card.overflow === 'false' && card.focusable === null && card.person.textOverflow === 'clip'), `${label}/${width}: natural short names remain complete and outside tab order`);
}
if (label === 'department-colors') {
await page.locator('.hm-orgchart-representative-preview').screenshot({ path: path.join(output, 'representative-normal.png') });
for (const [index, department] of [[0, 'business-management'], [1, 'business-planning'], [3, 'integrated-system']]) await page.locator('.hm-orgchart-preview-grid').nth(index).screenshot({ path: path.join(output, `${department}-normal.png`) });
await page.getByRole('button', { name: '긴 이름 검증 시작' }).click(); await ready(page);
for (const width of [390, 1440]) {
await page.setViewportSize({ width, height: 1000 }); await ready(page);
const value = await measure(page); measurements.push({ label: 'storybook-long', width, ...value }); verifyTypography(value, `storybook-long/${width}`);
check(value.cards.filter(card => card.overflow === 'true').length === 3 && value.cards.filter(card => card.overflow === 'true').every(card => card.person.textOverflow === 'ellipsis' && card.focusable === '0' && card.description), `storybook-long/${width}: ellipsis and focus only on overflowing names`);
await tooltipChecks(page, `storybook-long/${width}`);
}
await page.locator('.hm-orgchart-representative-preview').scrollIntoViewIfNeeded();
await page.locator('[data-card-asset-size=representative] .hm-orgchart-person-name').focus();
await page.screenshot({ path: path.join(output, 'representative-long-focus.png') });
await page.getByRole('button', { name: '긴 이름 검증 종료' }).click(); await ready(page);
check((await measure(page)).orphanTooltips === 0 && await page.locator('[role=tooltip]').count() === 0, 'Story long -> normal disposes tooltip nodes');
}
if (label === 'full-composition') {
await page.locator('.node[data-organization-id=business-management]').scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(output, 'composition-1440.png') });
}
if (label === 'html') {
await page.evaluate(async () => {
const next = structuredClone(window.OrgChartP1.data);
for (const id of ['mock-leader-00', 'mock-leader-01', 'mock-employee-01']) {
const person = next.employees.find(item => item.id === id);
person.name = '아주긴가상이름표시검증사례'; person.profileImage = `${location.origin}/profile-fixture.svg`;
}
await window.OrgChartP1.setData(next);
}); await ready(page);
const value = await measure(page); verifyTypography(value, 'html-long-images');
check(value.cards.filter(card => card.overflow === 'true').every(card => card.imageLoaded), 'long names keep original-size profile images');
await tooltipChecks(page, 'html-long-images');
}
await page.close();
}
for (const width of [390, 768, 1280, 1440]) {
const html = measurements.find(item => item.label === 'html' && item.width === width);
const react = measurements.find(item => item.label === 'react' && item.width === width);
check(JSON.stringify(html.cards) === JSON.stringify(react.cards), `HTML/React computed typography/content parity/${width}`);
}
const before = JSON.parse(await readFile(path.join(output, 'data-p1-before.json'), 'utf8'));
const after = JSON.parse(await readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
for (const employee of before.employees) employee.name = after.employees.find(item => item.id === employee.id).name;
check(JSON.stringify(before) === JSON.stringify(after), 'fixture change is exclusively synthetic display names; contract/count/membership/profile preserved');
check(errors.length === 0, 'browser console/runtime errors', errors);
} finally {
await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version(), results, errors, measurements }, null, 2));
await browser?.close();
for (const server of servers) await new Promise(resolve => server.close(resolve));
}
const failures = results.filter(result => result.status === 'FAIL');
console.log(JSON.stringify({ checks: results.length, failures: failures.map(result => ({ name: result.name, issues: result.detail?.length })) }, null, 2));
if (failures.length) process.exitCode = 1;
+201
View File
@@ -0,0 +1,201 @@
import http from 'node:http';
import path from 'node:path';
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
// Verify only the new card assets/layout; do not repeat Core/Summary suites.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-card-design');
await mkdir(output, { recursive: true });
const { chromium } = await import(process.env.ORGCHART_PLAYWRIGHT_MODULE ? pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href : 'playwright');
const results = [], measurements = [], errors = [], servers = [];
const check = (value, name, detail) => results.push({ name, status: value ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' };
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/profile-fixture.svg') {
response.setHeader('Content-Type', 'image/svg+xml');
return response.end('<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="16" fill="#45639f"/></svg>');
}
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(directory, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
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));
servers.push(server);
return `http://127.0.0.1:${server.address().port}`;
}
async function ready(page) {
await page.waitForFunction(() => {
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
return shadow?.querySelector('[data-card-asset]') && [...shadow.querySelectorAll('link')].every(link => !!link.sheet);
});
await page.evaluate(async () => {
await document.fonts.ready;
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
await Promise.all([...shadow.querySelectorAll('img')].map(img => img.decode().catch(() => {})));
});
}
async function measure(page) {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
const cards = [...shadow.querySelectorAll('[data-card-asset]')].map(card => {
const bounds = card.getBoundingClientRect(), background = card.querySelector('.hm-orgchart-card-background');
const avatar = card.querySelector('.hm-orgchart-avatar').getBoundingClientRect();
const logo = card.querySelector('.hm-orgchart-affiliation-mark')?.getBoundingClientRect();
const style = getComputedStyle(card), issues = [];
const contained = rect => rect.left >= bounds.left - 1 && rect.right <= bounds.right + 1 && rect.top >= bounds.top - 1 && rect.bottom <= bounds.bottom + 1;
const overlap = (a, b) => 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;
if (!contained(avatar)) issues.push('avatar outside card');
if (logo && !contained(logo)) issues.push('logo outside card');
for (const element of card.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title, .hm-orgchart-status-label')) {
const range = document.createRange(); range.selectNodeContents(element);
for (const rect of range.getClientRects()) {
if (!contained(rect)) issues.push(`${element.className}: text outside card`);
if (overlap(rect, avatar)) issues.push(`${element.className}: avatar overlap`);
if (overlap(rect, logo)) issues.push(`${element.className}: logo overlap`);
}
}
return { id: card.dataset.employeeId, department: card.dataset.cardAsset, size: card.dataset.cardAssetSize, kind: card.dataset.cardKind, inTopSlot: !!card.closest('.hm-orgchart-top-leaders'),
width: bounds.width, height: bounds.height, backgroundLoaded: background.complete && background.naturalWidth > 0,
naturalWidth: background.naturalWidth, naturalHeight: background.naturalHeight, embedded: background.src.startsWith('data:image/svg+xml'),
border: style.borderWidth, radius: style.borderRadius, fill: style.backgroundColor,
decoration: getComputedStyle(card, '::before').content, issues,
logoLoaded: !!card.querySelector('.hm-orgchart-affiliation-mark img')?.naturalWidth,
fallbackHidden: getComputedStyle(card.querySelector('.hm-orgchart-affiliation-fallback')).visibility === 'hidden',
logoBackground: getComputedStyle(card.querySelector('.hm-orgchart-affiliation-mark img')).backgroundColor,
avatarImage: !!card.querySelector('.hm-orgchart-avatar img')?.naturalWidth };
});
return { cards, documentOverflow: document.documentElement.scrollWidth > innerWidth + 1,
legacyCards: [...shadow.querySelectorAll('.hm-orgchart-employee:not([data-card-asset])')].map(card => ({ id: card.dataset.employeeId, status: card.dataset.workStatus, kind: card.dataset.cardKind })),
contrastCount: shadow.querySelectorAll('.hm-orgchart-contrast-result').length,
headings: [...shadow.querySelectorAll('.hm-orgchart-color-department-name')].map(item => item.textContent) };
});
}
function verify(value, label) {
check(value.cards.length > 0 && value.cards.every(card => card.backgroundLoaded && card.embedded), `${label}: actual embedded SVGs load`);
check(value.cards.every(card => card.width === (card.size === 'employee' ? 108 : 146) && card.height === (card.size === 'representative' ? 74 : card.size === 'leader' ? 68 : 52)
&& card.naturalWidth === card.width && card.naturalHeight === card.height), `${label}: native card sizes/ratios`);
check(value.cards.every(card => card.size === (card.kind === 'topLeader' ? 'representative' : card.kind === 'employee' ? 'employee' : 'leader')), `${label}: representative/leader/employee remain distinct`);
check(value.cards.every(card => card.border === '0px' && card.radius === '0px' && card.fill === 'rgba(0, 0, 0, 0)' && card.decoration === 'none'), `${label}: original SVG shape without legacy box decoration`);
check(value.cards.every(card => card.issues.length === 0), `${label}: text/avatar/logo bounds and collisions`, value.cards.filter(card => card.issues.length));
check(value.cards.every(card => card.logoLoaded && card.fallbackHidden && card.logoBackground === 'rgba(0, 0, 0, 0)'), `${label}: existing transparent logos/fallback preserved`);
check(!value.documentOverflow, `${label}: no page overflow`);
}
let browser;
try {
const origins = await Promise.all(['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist'].map(directory => serve(path.join(root, directory))));
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1, reducedMotion: 'reduce' });
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart'));
check(entries.filter(entry => !entry.tags?.includes('internal')).map(entry => entry.id).sort().join() === ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'].map(id => `orgchart--${id}`).sort().join(), 'five public Storybook menus preserved');
check(entries.filter(entry => entry.title === 'OrgChart/Content' && entry.tags.includes('internal')).length === 8, 'eight internal Stories preserved');
const assets = await readdir(path.join(root, 'packages/org-chart/src/assets/cards'));
const source = process.env.ORGCHART_CARD_SOURCE;
if (source) for (const file of assets.filter(file => file.endsWith('.svg') && !['대표자.svg', '사업관리팀.svg', '사업관리팀-sm.svg'].includes(file))) {
const hash = buffer => createHash('sha256').update(buffer).digest('hex');
check(hash(await readFile(path.join(source, file))) === hash(await readFile(path.join(root, 'packages/org-chart/src/assets/cards', file))), `original SHA-256 preserved: ${file}`);
}
const routes = [
['department-colors', `${origins[0]}/iframe.html?id=orgchart--department-colors&viewMode=story`],
['full-composition', `${origins[0]}/iframe.html?id=orgchart--full-composition&viewMode=story`],
['html', `${origins[1]}/p1.html`], ['react', `${origins[2]}/p1.html`],
['file-classic', pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href],
];
for (const [label, url] of routes) {
const page = await context.newPage();
page.on('pageerror', error => errors.push({ label, message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ label, message: message.text() }); });
await page.goto(url); await ready(page);
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1000 }); await ready(page);
const value = await measure(page); measurements.push({ label, width, ...value }); verify(value, `${label}/${width}`);
if (label === 'department-colors') {
check(value.cards.length === 13 && new Set(value.cards.filter(card => card.department !== 'representative').map(card => card.department)).size === 6, `${label}/${width}: six departments, both card sizes plus representative`);
check(value.contrastCount === 24 && value.headings.length === 6, `${label}/${width}: existing contrast display retained`);
} else {
check(new Set(value.cards.filter(card => card.department !== 'representative').map(card => card.department)).size === 6, `${label}/${width}: all six mapped branches present`);
check(value.cards.filter(card => card.department === 'representative').length === 1 && value.cards.some(card => card.id === 'mock-leader-00' && card.size === 'representative' && card.inTopSlot), `${label}/${width}: representative alone in top-leader slot`);
check(value.cards.some(card => card.id === 'mock-leader-01' && card.department === 'business-management' && card.size === 'leader')
&& value.cards.some(card => card.id === 'mock-employee-01' && card.department === 'business-management' && card.size === 'employee'), `${label}/${width}: business-management maps its own two sizes`);
check(value.legacyCards.some(card => card.status !== 'active'), `${label}/${width}: supplementary status cards unchanged`);
}
}
if (label === 'department-colors') {
await page.screenshot({ path: path.join(output, 'department-colors-1440.png'), fullPage: true });
await page.locator('.hm-orgchart-representative-preview').screenshot({ path: path.join(output, 'representative-card.png') });
for (const [number, department] of [[0, 'business-management'], [1, 'business-planning'], [2, 'talent-growth'], [3, 'integrated-system'], [4, 'design-planning'], [5, 'solution-sales']]) {
await page.locator('.hm-orgchart-preview-grid').nth(number).screenshot({ path: path.join(output, `${department}-cards.png`) });
}
}
if (label === 'full-composition') {
for (const department of ['business-management', 'business-planning', 'integrated-system', 'solution-sales']) {
await page.locator(`.node[data-organization-id="${department}"]`).scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(output, `composition-${department}-1440.png`) });
}
}
if (label === 'html') {
// A local image fixture tests the retained profile data layer without
// changing the source JSON or copying personal employee data.
await page.evaluate(async () => {
const next = structuredClone(window.OrgChartP1.data);
for (const id of ['mock-leader-00', 'mock-leader-02', 'mock-employee-02']) next.employees.find(item => item.id === id).profileImage = `${location.origin}/profile-fixture.svg`;
await window.OrgChartP1.setData(next);
});
await ready(page);
const value = await measure(page);
const profiles = value.cards.filter(card => ['mock-leader-00', 'mock-leader-02', 'mock-employee-02'].includes(card.id));
check(profiles.length === 3 && profiles.every(card => card.avatarImage && card.issues.length === 0), 'representative/leader/employee profile images retain bounds over SVG');
}
await page.close();
}
for (const width of [390, 768, 1280, 1440]) {
const canonical = value => JSON.stringify(value.cards);
const html = measurements.find(item => item.label === 'html' && item.width === width);
const react = measurements.find(item => item.label === 'react' && item.width === width);
const classic = measurements.find(item => item.label === 'file-classic' && item.width === width);
check(canonical(html) === canonical(react) && canonical(html) === canonical(classic), `HTML/React/file:// shared card rendering parity/${width}`);
}
const colorPage = await context.newPage();
const reference = await readFile(path.join(output, 'business-management-reference.png'));
const vector = await readFile(path.join(root, 'packages/org-chart/src/assets/cards/사업관리팀.svg'));
const comparison = await colorPage.evaluate(async ({ reference, vector }) => {
const render = async (src) => {
const image = new Image(); image.src = src; await image.decode();
const canvas = document.createElement('canvas'); canvas.width = 408; canvas.height = 191;
const ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0, 408, 191);
return { pixels: ctx.getImageData(0, 0, 408, 191).data, png: canvas.toDataURL() };
};
const a = await render(reference), b = await render(vector), errors = [];
for (let i = 0; i < a.pixels.length; i += 4) {
for (let channel = 0; channel < 3; channel++) {
const ca = a.pixels[i + channel] * a.pixels[i + 3] / 255 + 255 - a.pixels[i + 3];
const cb = b.pixels[i + channel] * b.pixels[i + 3] / 255 + 255 - b.pixels[i + 3];
errors.push(Math.abs(ca - cb));
}
}
const mean = errors.reduce((sum, value) => sum + value, 0) / errors.length;
errors.sort((x, y) => x - y);
return { meanRGBChannelError: mean, percentile95ChannelError: errors[Math.floor(errors.length * 0.95)], generatedPreview: b.png };
}, { reference: `data:image/png;base64,${reference.toString('base64')}`, vector: `data:image/svg+xml;base64,${vector.toString('base64')}` });
await writeFile(path.join(output, 'business-management-generated.png'), Buffer.from(comparison.generatedPreview.split(',')[1], 'base64'));
delete comparison.generatedPreview;
await writeFile(path.join(output, 'business-management-comparison.json'), JSON.stringify({ ...comparison, method: 'Chrome canvas, reference and generated SVG at reference raster dimensions, RGB over white, all pixels. Geometry/antialias differences included; not a perceptual certification.' }, null, 2));
check(comparison.meanRGBChannelError < 3 && comparison.percentile95ChannelError < 8, 'business-management PNG versus SVG rendered color similarity', comparison);
await colorPage.close();
check(errors.length === 0, 'browser console/runtime errors', errors);
} finally {
await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version(), results, errors, measurements }, null, 2));
await browser?.close();
for (const server of servers) await new Promise(resolve => server.close(resolve));
}
const failures = results.filter(result => result.status === 'FAIL');
console.log(JSON.stringify({ checks: results.length, failures: failures.map(result => ({ name: result.name, detail: result.detail?.length })) }, null, 2));
if (failures.length) process.exitCode = 1;
@@ -0,0 +1,142 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
// Only the eight final card requirements; existing 368-check evidence is untouched.
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href);
const output = 'test-results/org-chart-final-check';
await mkdir(output, { recursive: true });
const fixture = JSON.parse(await readFile('examples/org-chart/data-p1.json', 'utf8'));
const results = [], measurements = [], errors = [];
const check = (ok, name, actual) => results.push({ name, status: ok ? 'PASS' : 'FAIL', actual });
const browser = await chromium.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6031';
const collisionRepair = process.argv.includes('--collision-repair');
async function capture(story, scenario, data) {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.on('pageerror', error => errors.push({ story, scenario, message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ story, scenario, message: message.text() }); });
if (data) await page.route('**/examples/org-chart/data-p1.json*', route => route.fulfill({
status: 200, contentType: 'application/javascript', body: `export default ${JSON.stringify(data)};`,
}));
await page.goto(`${origin}/iframe.html?id=orgchart--${story}&viewMode=story`);
await page.waitForFunction(() => {
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
return shadow?.querySelector('[data-card-asset] .hm-orgchart-person-name[data-name-overflow]')
&& [...shadow.querySelectorAll('link')].every(link => link.sheet);
});
await page.evaluate(async () => {
await document.fonts.ready;
const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
await Promise.all([...root.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
});
const cards = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
const box = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height }; };
// Collision checks use painted logo pixels, not the transparent container.
const logoInk = mark => {
const img = mark?.querySelector('img');
if (!img?.naturalWidth) return null;
const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let left = canvas.width, right = -1, top = canvas.height, bottom = -1;
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) if (pixels[(y * canvas.width + x) * 4 + 3] > 16) {
left = Math.min(left, x); right = Math.max(right, x + 1); top = Math.min(top, y); bottom = Math.max(bottom, y + 1);
}
if (right < 0) return null;
const b = box(img), scale = Math.min(b.width / canvas.width, b.height / canvas.height);
const x = b.x + (b.width - canvas.width * scale) / 2, y = b.y + (b.height - canvas.height * scale) / 2;
return { x: x + left * scale, y: y + top * scale, right: x + right * scale, bottom: y + bottom * scale };
};
return [...shadow.querySelectorAll('[data-card-asset]')].map(card => {
const avatar = card.querySelector('.hm-orgchart-avatar'), text = card.querySelector('.hm-orgchart-person-text');
const name = card.querySelector('.hm-orgchart-person-name'), position = card.querySelector('.hm-orgchart-person-role');
const row = card.querySelector('.hm-orgchart-position-row');
const badge = card.querySelector('.hm-orgchart-job-title'), attrs = card.querySelector('.hm-orgchart-person-attributes');
const logo = card.querySelector('.hm-orgchart-affiliation-mark');
const nameStyle = getComputedStyle(name), positionStyle = getComputedStyle(position), cardStyle = getComputedStyle(card);
const a = box(avatar), t = box(text), n = box(name), p = box(position), b = badge && box(badge), l = logoInk(logo);
const overlap = (u, v) => Math.min(u.right, v.right) - Math.max(u.x, v.x) > 1 && Math.min(u.bottom, v.bottom) - Math.max(u.y, v.y) > 1;
const collisions = [];
if (overlap(a, t)) collisions.push('avatar/text');
if (b && overlap(b, a)) collisions.push('badge/avatar');
if (b && overlap(b, n)) collisions.push('badge/name');
if (l && overlap(l, a)) collisions.push('logo/avatar');
if (l && overlap(l, n)) collisions.push('logo/name');
if (l && b && overlap(l, b)) collisions.push('logo/badge');
if (l) {
const range = document.createRange(); range.selectNodeContents(position);
for (const r of range.getClientRects()) if (overlap(l, { x: r.x, y: r.y, right: r.right, bottom: r.bottom })) collisions.push('logo/position');
}
const bs = badge && getComputedStyle(badge);
return { id: card.dataset.employeeId, size: card.dataset.cardAssetSize, bounds: box(card), avatar: a,
columnGap: cardStyle.columnGap, photoTextGap: t.x - a.right, textGap: getComputedStyle(text).rowGap,
actualTextGap: n.y - p.bottom, nameColor: nameStyle.color,
row: row ? { display: getComputedStyle(row).display, alignItems: getComputedStyle(row).alignItems, gap: getComputedStyle(row).columnGap,
nameGap: n.y - box(row).bottom, badgeGap: b ? b.x - p.right : null, badgeMargin: bs?.margin ?? null, children: row.children.length } : null,
nameMargin: nameStyle.margin, namePadding: nameStyle.padding, positionMargin: positionStyle.margin, positionPadding: positionStyle.padding,
badge: badge ? { text: badge.textContent, fontSize: bs.fontSize, fontWeight: bs.fontWeight,
color: bs.color, background: bs.backgroundColor, bounds: b } : null,
attributesHeight: box(attrs).height, attributesPosition: getComputedStyle(attrs).position,
collisions };
});
});
measurements.push({ story, scenario, cards });
for (const card of cards) {
const prefix = `${story}/${scenario}/${card.id}/${card.size}`;
if (!collisionRepair) {
if (card.size === 'leader') check(card.columnGap === '10px' && Math.abs(card.photoTextGap - 10) < 0.1, `${prefix}: leader gap`, [card.columnGap, card.photoTextGap]);
if (card.size === 'employee') {
check(card.columnGap === '4px' && Math.abs(card.photoTextGap - 4) < 0.1, `${prefix}: employee gap`, [card.columnGap, card.photoTextGap]);
check(card.nameColor === 'rgb(50, 50, 50)', `${prefix}: name color`, card.nameColor);
}
check(card.textGap === '0px' && Math.abs(card.actualTextGap) < 0.1 && [card.nameMargin, card.namePadding, card.positionMargin, card.positionPadding].every(value => value === '0px'), `${prefix}: text gap`, card.actualTextGap);
const person = (data ?? fixture).employees.find(item => item.id === card.id);
check(card.badge?.text === person.jobTitle || (!person.jobTitle && !card.badge), `${prefix}: jobTitle-driven badge`, { jobTitle: person.jobTitle, badge: card.badge?.text ?? null });
if (!person.jobTitle) check(card.attributesHeight === 0 && card.attributesPosition === 'absolute', `${prefix}: no empty badge space`, card.attributesHeight);
if (card.badge && card.size !== 'representative') check(card.badge.fontSize === (card.size === 'leader' ? '9px' : '8.5px') && card.badge.fontWeight === (card.size === 'leader' ? '700' : '600'), `${prefix}: badge typography`, card.badge);
if (card.size !== 'representative') {
check(card.row?.display === 'flex' && card.row.alignItems === 'center' && card.row.gap === '4px' && Math.abs(card.row.nameGap) < 0.1, `${prefix}: position/badge row and zero name gap`, card.row);
if (card.badge) check(Math.abs(card.row.badgeGap - 4) < 0.1 && card.row.badgeMargin === '0px', `${prefix}: actual position/badge gap and margin`, card.row);
else check(card.row.children === 1, `${prefix}: position only without badge`, card.row.children);
}
const dimensions = card.size === 'employee' ? [108, 52, 24] : card.size === 'leader' ? [146, 68, 32] : [146, 74, 36];
check(card.bounds.width === dimensions[0] && card.bounds.height === dimensions[1] && card.avatar.width === dimensions[2] && card.avatar.height === dimensions[2], `${prefix}: preserved dimensions`, dimensions);
}
check(card.collisions.length === 0, `${prefix}: related collisions`, card.collisions);
}
if (!collisionRepair && (scenario === 'normal' || scenario === 'roles')) await page.screenshot({ path: `${output}/${story}-${scenario}.png`, fullPage: true });
await page.close();
return cards;
}
try {
const sample = await capture('department-colors', 'normal');
await capture('full-composition', 'normal');
if (!collisionRepair) {
const smallIds = sample.filter(card => card.size === 'employee').slice(0, 2).map(card => card.id);
check(smallIds.length === 2, 'two small-card role fixtures available', smallIds);
const roles = structuredClone(fixture);
smallIds.forEach((id, index) => { roles.employees.find(item => item.id === id).jobTitle = index ? '셀장' : '팀장'; });
const noRole = structuredClone(fixture);
const leaderId = sample.find(card => card.size === 'leader').id;
noRole.employees.find(item => item.id === leaderId).jobTitle = null;
for (const story of ['department-colors', 'full-composition']) {
const cards = await capture(story, 'roles', roles);
for (const [index, id] of smallIds.entries()) check(cards.some(card => card.id === id && card.size === 'employee' && card.badge?.text === (index ? '셀장' : '팀장')), `${story}: small ${index ? 'cell' : 'team'} badge`, id);
const empty = await capture(story, 'no-role', noRole);
check(empty.some(card => card.id === leaderId && !card.badge && card.attributesHeight === 0), `${story}: leader without role has no badge/space`, leaderId);
}
}
} finally { await browser.close(); }
check(errors.length === 0, 'browser console/runtime errors', errors);
const baseline = JSON.parse(await readFile(`${output}/baseline.json`, 'utf8'));
const changed = [];
for (const [file, hash] of Object.entries(baseline)) {
const current = createHash('sha256').update(await readFile(file)).digest('hex');
if (current !== hash) changed.push(file);
}
if (!collisionRepair) check(changed.length === 3 && changed.includes('packages/org-chart/src/core/employee-card.ts') && changed.includes('packages/org-chart/src/styles/employee-card.css') && changed.includes('packages/org-chart/src/styles/summary-sections.css'), 'preserved SVG/logo/JSON/API/Stories/368 evidence/Guard; only 3 existing files changed', changed);
await writeFile(`${output}/${collisionRepair ? 'card-collision-repair' : 'browser'}.json`, JSON.stringify({ results, measurements, errors, changed, fixtureNote: 'Role/no-role scenarios alter only intercepted in-memory browser JSON modules; repository JSON is unchanged.' }, null, 2));
console.log(JSON.stringify({ checks: results.length, pass: results.filter(item => item.status === 'PASS').length, failures: results.filter(item => item.status === 'FAIL'), errors, changed }, null, 2));
if (results.some(item => item.status === 'FAIL')) process.exitCode = 1;
@@ -0,0 +1,70 @@
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href);
const output = 'test-results/org-chart-final-check';
const baselineMode = process.argv.includes('--baseline');
const repairMode = process.argv.includes('--geometry-repair');
const browser = await chromium.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
const measurements = [], results = [], errors = [];
const check = (ok, name, actual) => results.push({ name, status: ok ? 'PASS' : 'FAIL', actual });
const previous = baselineMode ? [] : JSON.parse(await readFile(`${output}/logo-before.json`, 'utf8'));
try {
for (const story of ['department-colors', 'full-composition']) {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
let expectedImageFailure = false;
page.on('pageerror', error => errors.push(error.message));
page.on('console', item => { if (item.type() === 'error' && !(expectedImageFailure && item.text().includes('ERR_INVALID_URL'))) errors.push(item.text()); });
await page.goto(`${process.env.ORGCHART_STORY_ORIGIN}/iframe.html?id=orgchart--${story}&viewMode=story`);
await page.waitForFunction(() => {
const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
return root?.querySelector('[data-card-asset]') && [...root.querySelectorAll('link')].every(link => link.sheet);
});
await page.evaluate(async () => {
await document.fonts.ready;
const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
await Promise.all([...root.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
});
const logos = await page.evaluate(() => {
const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
const rect = element => { const b = element.getBoundingClientRect(); return { x: b.x, y: b.y, width: b.width, height: b.height }; };
return [...root.querySelectorAll('[data-card-kind] .hm-orgchart-affiliation-mark')].map(mark => {
const img = mark.querySelector('img'), style = getComputedStyle(mark), fallback = mark.querySelector('.hm-orgchart-affiliation-fallback');
return { id: mark.closest('[data-card-kind]').dataset.employeeId, kind: mark.closest('[data-card-kind]').dataset.cardKind,
bounds: rect(mark), image: img ? rect(img) : null, imageFit: img && getComputedStyle(img).objectFit,
background: style.backgroundColor, borders: [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth],
borderStyle: style.borderTopStyle, shadow: style.boxShadow,
imageBackground: img && getComputedStyle(img).backgroundColor,
before: getComputedStyle(mark, '::before').content, after: getComputedStyle(mark, '::after').content,
loaded: !!img?.naturalWidth, fallbackVisibility: getComputedStyle(fallback).visibility };
});
});
measurements.push({ story, logos });
if (!baselineMode) {
for (const [index, logo] of logos.entries()) {
const before = previous.find(item => item.story === story).logos[index];
if (!repairMode) check(logo.background === 'rgba(0, 0, 0, 0)' && logo.borders.every(value => value === '0px') && logo.borderStyle === 'none' && logo.shadow === 'none', `${story}/${logo.id}: transparent borderless container`, logo);
check(JSON.stringify(logo.bounds) === JSON.stringify(before.bounds) && JSON.stringify(logo.image) === JSON.stringify(before.image) && logo.imageFit === 'contain', `${story}/${logo.id}: unchanged container/image geometry`, { before, after: logo });
if (!repairMode) check(logo.loaded && logo.fallbackVisibility === 'hidden' && logo.imageBackground === 'rgba(0, 0, 0, 0)' && ['none', 'normal'].includes(logo.before) && ['none', 'normal'].includes(logo.after), `${story}/${logo.id}: loaded transparent image and no fallback/decorations`, logo);
}
await page.locator('[data-employee-id="mock-leader-04"]').screenshot({ path: `${output}/${story}-logo-card.png` });
expectedImageFailure = true;
const failure = await page.evaluate(async () => {
const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
const mark = root.querySelector('[data-card-asset] .hm-orgchart-affiliation-mark');
mark.querySelector('img').src = 'data:image/png;base64,invalid-image';
await new Promise(resolve => setTimeout(resolve, 100));
const fallback = mark.querySelector('.hm-orgchart-affiliation-fallback');
return { noImage: !mark.querySelector('img'), available: mark.dataset.logoAvailable, visibility: getComputedStyle(fallback).visibility, text: fallback.textContent };
});
if (!repairMode) check(failure.noImage && failure.available === 'false' && failure.visibility === 'visible' && !!failure.text, `${story}: failed image retains fallback`, failure);
}
await page.close();
}
} finally { await browser.close(); }
if (baselineMode) await writeFile(`${output}/logo-before.json`, JSON.stringify(measurements, null, 2));
else {
check(errors.length === 0, 'browser console/runtime errors', errors);
await writeFile(`${output}/${repairMode ? 'logo-geometry-repair' : 'logo-browser'}.json`, JSON.stringify({ results, measurements, errors }, null, 2));
console.log(JSON.stringify({ checks: results.length, pass: results.filter(item => item.status === 'PASS').length, failures: results.filter(item => item.status === 'FAIL'), errors }, null, 2));
if (results.some(item => item.status === 'FAIL')) process.exitCode = 1;
}
@@ -0,0 +1,107 @@
import http from 'node:http';
import path from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href);
const output = 'test-results/org-chart-final-check';
const baselineMode = process.argv.includes('--baseline');
const colorsRepair = process.argv.includes('--colors-repair');
const browser = await chromium.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
const results = [], measurements = [], errors = [];
const check = (ok, name, actual) => results.push({ name, status: ok ? 'PASS' : 'FAIL', actual });
async function measure(url, label) {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.on('pageerror', error => errors.push({ label, message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ label, message: message.text() }); });
await page.goto(url);
await page.waitForFunction(() => {
const root = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return root?.querySelector('.hm-orgchart-status-group') && [...root.querySelectorAll('link')].every(link => link.sheet);
});
await page.evaluate(async () => {
await document.fonts.ready;
const root = document.querySelector('.hm-orgchart-host').shadowRoot;
await Promise.all([...root.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
});
const value = await page.evaluate(() => {
const root = document.querySelector('.hm-orgchart-host').shadowRoot;
const properties = (element, keys) => { const s = getComputedStyle(element); return Object.fromEntries(keys.map(key => [key, s[key]])); };
const typography = element => properties(element, ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'margin', 'padding']);
const box = element => { const b = element.getBoundingClientRect(); return { x: b.x, y: b.y, right: b.right, bottom: b.bottom }; };
const overlap = (a, b) => Math.min(a.right, b.right) - Math.max(a.x, b.x) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.y, b.y) > 1;
const organization = [...root.querySelectorAll('[data-card-asset]')].map(card => ({ id: card.dataset.employeeId,
card: properties(card, ['backgroundImage', 'border', 'width', 'height', 'columnGap']),
name: properties(card.querySelector('.hm-orgchart-person-name'), ['color', 'fontSize', 'fontWeight']),
logo: properties(card.querySelector('.hm-orgchart-affiliation-mark img'), ['filter', 'opacity', 'width', 'height']) }));
const groups = [...root.querySelectorAll('.hm-orgchart-status-group')].map(group => ({
status: group.dataset.workStatus, titleText: group.querySelector('h3').textContent,
title: properties(group.querySelector('h3'), ['backgroundImage', 'border', 'boxShadow']),
cards: [...group.querySelectorAll('.hm-orgchart-employee')].map(card => {
const role = card.querySelector('.hm-orgchart-person-role'), name = card.querySelector('.hm-orgchart-person-name');
const avatar = card.querySelector('.hm-orgchart-avatar'), mark = card.querySelector('.hm-orgchart-affiliation-mark'), image = mark.querySelector('img');
const collisions = [];
const roleRange = document.createRange(); roleRange.selectNodeContents(role);
const nameRange = document.createRange(); nameRange.selectNodeContents(name);
for (const [kind, range] of [['role', roleRange], ['name', nameRange]]) for (const r of range.getClientRects()) {
const textBox = { x: r.x, y: r.y, right: r.right, bottom: r.bottom };
if (overlap(textBox, box(avatar))) collisions.push(`${kind}/avatar`);
if (overlap(textBox, box(image))) collisions.push(`${kind}/logo`);
}
return { id: card.dataset.employeeId, card: properties(card, ['margin', 'backgroundImage', 'border', 'borderRadius', 'cursor', 'columnGap', 'filter', 'opacity']),
roleColor: getComputedStyle(role).color, nameColor: getComputedStyle(name).color,
roleTypography: typography(role), nameTypography: typography(name),
logo: properties(image, ['filter', 'opacity']), mark: properties(mark, ['backgroundColor', 'border', 'boxShadow', 'filter', 'opacity']), collisions };
})
}));
return { organization, groups, radiusVariable: getComputedStyle(root.querySelector('.hm-orgchart-shell')).getPropertyValue('--br-3_5').trim(),
resolvedRadius: getComputedStyle(root.querySelector('.hm-orgchart-shell')).getPropertyValue('--hm-orgchart-resolved-radius-container').trim() };
});
if (!baselineMode) await page.locator('.hm-orgchart-status-summary').screenshot({ path: `${output}/${label}-status.png` });
await page.close();
return value;
}
let server;
try {
const storyUrl = `${process.env.ORGCHART_STORY_ORIGIN}/iframe.html?id=orgchart--full-composition&viewMode=story`;
const react = await measure(storyUrl, 'react');
if (baselineMode) await writeFile(`${output}/status-before.json`, JSON.stringify(react, null, 2));
else {
const baseline = JSON.parse(await readFile(`${output}/status-before.json`, 'utf8'));
if (!colorsRepair) check(JSON.stringify(react.organization) === JSON.stringify(baseline.organization), 'normal organization cards and colored logos unchanged', react.organization.length);
const directory = path.resolve('examples/org-chart/dist');
server = http.createServer(async (req, res) => {
try {
const file = path.resolve(directory, '.' + decodeURIComponent(new URL(req.url, 'http://localhost').pathname));
if (path.relative(directory, file).startsWith('..')) return res.writeHead(403).end();
res.setHeader('Content-Type', ({ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.ttf': 'font/ttf' })[path.extname(file)] ?? 'application/octet-stream');
res.end(await readFile(file));
} catch { res.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const html = await measure(`http://127.0.0.1:${server.address().port}/p1.html`, 'html');
measurements.push({ label: 'react', ...react }, { label: 'html', ...html });
check(JSON.stringify(react.groups) === JSON.stringify(html.groups), 'HTML and React status groups computed styles match', react.groups.map(group => group.status));
for (const group of react.groups) {
const before = baseline.groups.find(item => item.status === group.status);
if (colorsRepair) {
for (const card of group.cards) check(card.roleColor === 'rgb(85, 95, 106)' && card.nameColor === 'rgb(62, 70, 80)', `${group.status}/${card.id}: repaired text colors`, [card.roleColor, card.nameColor]);
continue;
}
check(group.title.backgroundImage === 'linear-gradient(rgb(135, 144, 156) 0%, rgb(104, 114, 126) 100%)' && group.title.border === '1px solid rgba(92, 101, 112, 0.52)' && group.title.boxShadow === 'rgba(75, 84, 96, 0.18) 0px 7px 16px 0px', `${group.status}: title gradient/border/shadow`, group.title);
for (const card of group.cards) {
const old = before.cards.find(item => item.id === card.id);
check(card.card.backgroundImage === 'linear-gradient(rgb(244, 245, 246) 0%, rgb(223, 227, 231) 100%)' && card.card.border === '1px solid rgba(120, 130, 142, 0.35)' && card.card.borderRadius === '4px' && card.card.margin === '0px' && card.card.cursor === 'pointer', `${group.status}/${card.id}: card surface`, card.card);
check(card.roleColor === 'rgb(85, 95, 106)' && card.nameColor === 'rgb(62, 70, 80)', `${group.status}/${card.id}: text colors`, [card.roleColor, card.nameColor]);
check(JSON.stringify(card.roleTypography) === JSON.stringify(old.roleTypography) && JSON.stringify(card.nameTypography) === JSON.stringify(old.nameTypography) && card.card.columnGap === old.card.columnGap, `${group.status}/${card.id}: existing typography and gap preserved`, { role: card.roleTypography, name: card.nameTypography, gap: card.card.columnGap });
check(card.logo.filter === 'grayscale(1)' && card.logo.opacity === '0.72' && card.mark.filter === 'none' && card.mark.opacity === '1' && card.card.filter === 'none' && card.card.opacity === '1', `${group.status}/${card.id}: image-only filter/opacity`, card.logo);
check(card.mark.backgroundColor === 'rgba(0, 0, 0, 0)' && card.mark.border === '0px none rgb(89, 101, 117)' && card.mark.boxShadow === 'none', `${group.status}/${card.id}: transparent logo frame`, card.mark);
check(card.collisions.length === 0, `${group.status}/${card.id}: profile/text/logo collisions`, card.collisions);
}
check(group.titleText === before.titleText && group.cards.length === before.cards.length, `${group.status}: same employee IDs/counts`, group.cards.map(card => card.id));
}
check(errors.length === 0, 'browser errors', errors);
await writeFile(`${output}/${colorsRepair ? 'status-colors-repair' : 'status-browser'}.json`, JSON.stringify({ results, measurements, errors }, null, 2));
console.log(JSON.stringify({ checks: results.length, pass: results.filter(item => item.status === 'PASS').length, failures: results.filter(item => item.status === 'FAIL'), errors }, null, 2));
if (results.some(item => item.status === 'FAIL')) process.exitCode = 1;
}
} finally { await browser.close(); if (server) await new Promise(resolve => server.close(resolve)); }
+139
View File
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { performance } from 'node:perf_hooks';
import { parse } from '@babel/parser';
import postcss from 'postcss';
import { themeAnalysis } from '../../scripts/guard-theme-analysis.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const script = fileURLToPath(import.meta.url);
const rawFile = path.join(root, 'test-results/org-chart-p1/guard-storybook-menu-recheck.json');
const output = path.join(root, 'test-results/org-chart-quality/guard-reproduction.json');
const started = performance.now();
const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const rawHashBefore = hash(rawFile);
const raw = JSON.parse(fs.readFileSync(rawFile, 'utf8'));
const analyzerHashBefore = hash(path.join(root, 'scripts/guard-theme-analysis.mjs'));
function walk(node, visitor, parent = null, field = null) {
if (!node || typeof node !== 'object') return;
if (node.type) visitor(node, parent, field);
for (const [key, value] of Object.entries(node)) {
if (['loc', 'start', 'end', 'comments', 'tokens', 'extra'].includes(key)) continue;
if (Array.isArray(value)) value.forEach(item => walk(item, visitor, node, key));
else if (value && typeof value === 'object') walk(value, visitor, node, key);
}
}
/** In-memory fixtures use the function's existing injected parser interface.
* The script itself is a readable CSS-file sentinel because the real analyzer
* reads files before invoking its parser. The parser returns the supplied
* fixture CSS AST; no fixture files or production code are changed. */
function analyze(label, jsSource, cssSource) {
const jsFile = `memory://${label}.js`;
const records = [{ file: jsFile, edges: [], ast: parse(jsSource, { sourceType: 'module' }) }];
if (cssSource !== undefined) records.push({ file: script, edges: [], css: true });
const issues = [];
const result = themeAnalysis({
records, workspace: root,
postcss: { parse: () => postcss.parse(cssSource ?? '', { from: `memory://${label}.css` }) },
walk, selectedFile: () => true,
add: (severity, code, file, node, message) => issues.push({
severity, code, file: file === script ? `memory://${label}.css` : file,
line: node?.loc?.start?.line ?? node?.source?.start?.line ?? 1, message,
}),
});
return {
label, fixture: { js: jsSource, css: cssSource ?? null },
analyzerStatus: issues.some(issue => issue.severity === 'FAIL') ? 'FAIL'
: issues.length ? 'NOT VERIFIED' : 'PASS',
definitions: result.definitions, references: result.references.map(ref => ({
...ref, file: ref.file === script ? `memory://${label}.css` : ref.file,
})), dynamic: result.dynamic, incomplete: result.incomplete, issues,
};
}
const measurements = [];
const assertions = [];
function check(name, condition) {
assertions.push({ name, status: condition ? 'PASS' : 'FAIL' });
assert.ok(condition, name);
}
let failure;
try {
const graphSource = 'const background = "var(--hm-orgchart-resolved-bg-surface)";';
const excluded = analyze('shadow-css-excluded', graphSource);
const included = analyze('shadow-css-included', graphSource,
'.hm-orgchart-shell { --hm-orgchart-resolved-bg-surface: #ffffff; }');
measurements.push(excluded, included);
check('Excluded Shadow CSS reference reproduces TOKEN-001 FAIL',
excluded.issues.some(issue => issue.code === 'TOKEN-001' && issue.severity === 'FAIL'));
check('Connected CSS declaration resolves the identical reference',
included.analyzerStatus === 'PASS' && included.definitions.includes('--hm-orgchart-resolved-bg-surface'));
const setterSource = 'element.style.setProperty("--hm-orgchart-current-border", "#194d82");\n'
+ 'element.style.setProperty("--hm-orgchart-accent", "var(--hm-orgchart-current-border)");';
const setterOnly = analyze('set-property-not-collected', setterSource);
const setterWithCss = analyze('set-property-with-css-declaration', setterSource,
'.hm-orgchart-group { --hm-orgchart-current-border: #194d82; }');
measurements.push(setterOnly, setterWithCss);
check('Actual literal setProperty definition is absent from analyzer definitions',
!setterOnly.definitions.includes('--hm-orgchart-current-border') && setterOnly.analyzerStatus === 'FAIL');
check('CSS definition resolves setProperty reference without changing JS fixture',
setterWithCss.analyzerStatus === 'PASS');
const optional = analyze('optional-public-input-with-fallback', '',
'.hm-orgchart-shell { --hm-orgchart-resolved-fg-primary: var(--hm-orgchart-fg-primary, #10243b); }');
measurements.push(optional);
const optionalReference = optional.references.find(ref => ref.name === '--hm-orgchart-fg-primary');
check('Optional standalone input with valid fallback is still classified TOKEN-001 FAIL',
optionalReference?.fallback === true && optionalReference.status === 'FAIL'
&& optional.issues.some(issue => issue.code === 'TOKEN-001' && issue.severity === 'FAIL'));
// Diagnostic namespace control only. Renaming OrgChart inputs is NOT a fix.
const otherNamespace = analyze('external-input-namespace-control', '',
'.content { --resolved-fg-primary: var(--external-fg-primary, #10243b); }');
measurements.push(otherNamespace);
check('Same fallback shape outside HM namespace is NOT VERIFIED, not FAIL',
otherNamespace.analyzerStatus === 'NOT VERIFIED' && otherNamespace.references[0]?.fallback === true);
check('Historical raw report remains FAIL with the original three TOKEN-001 findings',
raw.status === 'FAIL' && raw.staticStatus === 'FAIL'
&& raw.issues.filter(issue => issue.code === 'TOKEN-001').length === 3);
check('Original Guard output and analyzer source hashes remain unchanged',
hash(rawFile) === rawHashBefore && hash(path.join(root, 'scripts/guard-theme-analysis.mjs')) === analyzerHashBefore);
} catch (error) {
failure = { name: error.name, message: error.message };
}
const report = {
schemaVersion: 1,
purpose: 'Minimal reproduction of existing analyzer behavior; assertion PASS is NOT OrgChart quality or Guard PASS',
reproductionStatus: failure ? 'FAIL' : 'PASS',
generatedAt: new Date().toISOString(), durationMs: Math.round((performance.now() - started) * 100) / 100,
assertions, measurements,
originalGuard: {
file: 'test-results/org-chart-p1/guard-storybook-menu-recheck.json',
sha256Before: rawHashBefore, sha256After: hash(rawFile), status: raw.status, staticStatus: raw.staticStatus,
relevantIssues: raw.issues.filter(issue => ['TOKEN-001', 'TOKEN-002'].includes(issue.code)),
},
analyzer: { file: 'scripts/guard-theme-analysis.mjs', sha256Before: analyzerHashBefore,
sha256After: hash(path.join(root, 'scripts/guard-theme-analysis.mjs')) },
scope: 'In-memory fixtures passed directly to the actual exported themeAnalysis; no full Guard suite or browser checks',
limitations: [
'This demonstrates analyzer limitations; it does not implement a revised Guard policy',
'Fixture graph inclusion is controlled directly; real Shadow URL dataflow is not implemented or rerun',
'The CSS declaration control is a fixture, not a production dummy declaration proposal',
'The external namespace control is diagnostic; production namespace renaming would be a bypass',
'Original Guard FAIL remains unchanged; runtime evidence cannot overwrite that FAIL',
'Common Guard changes require separate user approval',
],
...(failure ? { failure } : {}),
};
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify({ reproductionStatus: report.reproductionStatus, assertions: assertions.length,
measurements: measurements.length, originalGuardStatus: raw.status, output: path.relative(root, output) }));
if (failure) process.exitCode = 1;
+568
View File
@@ -0,0 +1,568 @@
import http from 'node:http';
import path from 'node:path';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
// Verification tooling is supplied by the environment, never shipped in Core.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p1');
await mkdir(output, { recursive: true });
const fixture = JSON.parse(await readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
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', '.png': 'image/png', '.svg': 'image/svg+xml' };
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
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 results = [], errors = [], metrics = [], snapshots = [];
const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
const clone = data => structuredClone(data);
const dataWith = (data, version) => { const next = clone(data); next.metadata.dataVersion = version; return next; };
function captureErrors(page, label) {
page.on('pageerror', error => errors.push({ label, type: 'pageerror', message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ label, type: 'console', message: message.text() }); });
}
async function ready(page, transport, version) {
await page.waitForFunction(({ transport, version }) => {
const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React;
const state = transport === 'vanilla' ? api?.chart?.getState() : api?.events?.at(-1);
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return state?.status === 'ready' && state.dataVersion === version && shadow?.querySelector('.hm-orgchart-composition .node');
}, { transport, version }, { timeout: 20000 });
await page.evaluate(async () => {
await document.fonts.ready;
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
await Promise.all([...shadow.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(image => image.complete ? Promise.resolve() : new Promise(resolve => {
image.addEventListener('load', resolve, { once: true }); image.addEventListener('error', resolve, { once: true });
})));
});
}
async function setData(page, transport, data) {
await page.evaluate(async ({ transport, data }) => {
const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React;
await api.setData(data);
}, { transport, data });
await ready(page, transport, data.metadata.dataVersion);
}
/** Transport-neutral snapshot: no generated IDs, stylesheet hashes or host URLs. */
async function snapshot(page) {
return page.evaluate(() => {
const host = document.querySelector('.hm-orgchart-host');
const shadow = host.shadowRoot;
const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
const employees = scope => [...scope.querySelectorAll('.hm-orgchart-employee')].map(card => ({
id: card.dataset.employeeId, kind: card.dataset.cardKind, status: card.dataset.workStatus,
name: text(card.querySelector('.hm-orgchart-person-name')), position: text(card.querySelector('.hm-orgchart-person-role')),
jobTitle: text(card.querySelector('.hm-orgchart-job-title')),
affiliation: card.querySelector('.hm-orgchart-affiliation-mark')?.dataset.affiliationId ?? null,
}));
return {
heading: text(shadow.querySelector('.hm-orgchart-heading')),
title: text(shadow.querySelector('.hm-orgchart-title')),
hub: text(shadow.querySelector('.hm-orgchart-hub')),
nodes: [...shadow.querySelectorAll('.node')].map(node => {
const hierarchy = node.closest('.hierarchy');
const parent = hierarchy?.parentElement?.closest('.hierarchy')?.querySelector(':scope > .node');
return { id: node.dataset.organizationId, type: node.dataset.organizationType, depth: Number(node.dataset.depth), parentId: parent?.dataset.organizationId ?? null,
name: text(node.querySelector('.hm-orgchart-organization-name')), employees: employees(node) };
}),
topLeaders: employees(shadow.querySelector('.hm-orgchart-top-leaders') ?? document.createElement('div')),
components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component),
positions: shadow.querySelector('.hm-orgchart-position-summary') ? {
total: text(shadow.querySelector('.hm-orgchart-position-summary .hm-orgchart-raw-total')),
policy: [...shadow.querySelectorAll('.hm-orgchart-position-summary .hm-orgchart-policy')].map(text),
headers: [...shadow.querySelectorAll('.hm-orgchart-position-table thead th')].map(text),
rows: [...shadow.querySelectorAll('.hm-orgchart-position-table tbody tr')].map(row => ({ id: row.dataset.organizationId, name: text(row.querySelector('th')), cells: [...row.querySelectorAll('td')].map(text) })),
totals: [...shadow.querySelectorAll('.hm-orgchart-position-table tfoot td')].map(text),
statuses: text(shadow.querySelector('.hm-orgchart-status-totals')),
} : null,
affiliations: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item')].map(item => ({
id: item.dataset.affiliationId, count: text(item.querySelector('dd')),
label: item.querySelector('.hm-orgchart-affiliation-mark')?.getAttribute('aria-label'),
logo: item.querySelector('.hm-orgchart-affiliation-mark img')?.getAttribute('src') ?? null,
})),
affiliationTotal: text(shadow.querySelector('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total')),
statuses: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(group => ({ status: group.dataset.workStatus,
heading: text(group.querySelector('h3')), employees: employees(group), empty: text(group.querySelector('.hm-orgchart-content-empty')) })),
};
});
}
function expectedCounts(data) {
const employees = [...new Map(data.employees.map(employee => [employee.id, employee])).values()];
const organizations = new Map(data.organizations.map(org => [org.id, org]));
const positions = new Map(), affiliations = new Map(data.affiliations.map(item => [item.id, 0]));
const direct = new Map(data.organizations.map(org => [org.id, 0]));
const inclusive = new Map(direct), matrix = new Map(data.organizations.map(org => [org.id, new Map()]));
const statuses = { active: 0, dispatched: 0, onLeave: 0 };
for (const employee of employees) {
positions.set(employee.position, (positions.get(employee.position) ?? 0) + 1);
affiliations.set(employee.affiliationId, (affiliations.get(employee.affiliationId) ?? 0) + 1);
direct.set(employee.organizationId, direct.get(employee.organizationId) + 1);
const row = matrix.get(employee.organizationId); row.set(employee.position, (row.get(employee.position) ?? 0) + 1);
statuses[employee.workStatus]++;
let org = organizations.get(employee.organizationId);
while (org) { inclusive.set(org.id, inclusive.get(org.id) + 1); org = organizations.get(org.parentId); }
}
return { total: employees.length, employees, positions, affiliations, direct, inclusive, matrix, statuses };
}
function checkCounts(current, data, label) {
const expected = expectedCounts(data);
const positionNames = current.positions.headers.slice(1, -2);
check(current.positions.rows.length === data.organizations.length, `${label}: every organization has a cross-table row`);
check(current.positions.rows.every(row => {
const cells = row.cells.map(Number);
return positionNames.every((position, index) => cells[index] === (expected.matrix.get(row.id)?.get(position) ?? 0))
&& cells.at(-2) === expected.direct.get(row.id) && cells.at(-1) === expected.inclusive.get(row.id);
}), `${label}: direct organization×position cells and inclusive counts match unique primary membership`, current.positions.rows);
check(positionNames.every((position, index) => Number(current.positions.totals[index]) === expected.positions.get(position))
&& Number(current.positions.totals.at(-2)) === expected.total && current.positions.totals.at(-1) === '—', `${label}: direct totals are disjoint; inclusive counts are not added`, current.positions.totals);
check(current.positions.total.includes(`${expected.total}명`) && current.affiliationTotal.includes(`${expected.total}명`), `${label}: Position and Affiliation totals reuse the same Raw Count`);
check(current.affiliations.length === data.affiliations.length && current.affiliations.every(item => parseInt(item.count) === expected.affiliations.get(item.id)), `${label}: every declared affiliation count is correct`, current.affiliations.map(({ id, count }) => ({ id, count })));
check(current.statuses.every(group => {
const matching = expected.employees.filter(employee => employee.workStatus === group.status);
return parseInt(group.heading.split('·').at(-1)) === matching.length && same(group.employees.map(employee => employee.id).sort(), matching.map(employee => employee.id).sort());
}), `${label}: status groups use original employee IDs without inflating total`, current.statuses.map(group => ({ status: group.status, ids: group.employees.map(employee => employee.id) })));
check(current.positions.policy.some(text => text.includes('총원 미확정')) && current.positions.policy.some(text => text.includes('조직장') && text.includes('파견') && text.includes('휴직')), `${label}: Raw inclusion policy is visible; business total remains unapproved`);
}
async function measure(page) {
return page.evaluate(() => {
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host');
const shadow = host.shadowRoot;
const viewport = shadow.querySelector('.hm-orgchart-viewport');
const summary = shadow.querySelector('.hm-orgchart-summary-scroll');
const dimensions = element => element && ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX });
const textElements = [...shadow.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-organization-name')].filter(element => element.getBoundingClientRect().height > 0);
const cards = [...shadow.querySelectorAll('.node')].map(element => element.getBoundingClientRect()).filter(rect => rect.height > 0);
const sample = shadow.querySelector('.hm-orgchart-employee, .hm-orgchart-position-summary');
return {
document: dimensions(document.documentElement), host: dimensions(host), viewport: dimensions(viewport), summary: dimensions(summary),
ids: [...shadow.querySelectorAll('.hm-orgchart-domain-id')].map(element => ({ text: element.textContent, title: element.title, whiteSpace: getComputedStyle(element).whiteSpace, height: element.clientHeight, scrollHeight: element.scrollHeight })),
textFits: textElements.every(element => element.scrollWidth <= element.clientWidth + 1 && element.scrollHeight <= element.clientHeight + 1),
textIssues: textElements.filter(element => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1).map(element => ({ className: element.className, text: element.textContent, ...dimensions(element) })),
nodeOverlap: cards.some((a, index) => cards.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)),
font: sample ? getComputedStyle(sample).fontFamily : null, foreground: sample ? getComputedStyle(sample).color : null,
background: sample ? getComputedStyle(sample).backgroundColor : null, radius: sample ? getComputedStyle(sample).borderRadius : null,
tokens: [...getComputedStyle(shadow.querySelector('.hm-orgchart-shell'))].filter(key => key.startsWith('--hm-orgchart-')).map(key => ({ key, value: getComputedStyle(shadow.querySelector('.hm-orgchart-shell')).getPropertyValue(key).trim() })),
cssLoaded: [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet),
portraitsFallback: [...shadow.querySelectorAll('.hm-orgchart-avatar')].every(avatar => avatar.textContent.trim() && !avatar.querySelector('img')),
logos: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item .hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, available: mark.dataset.logoAvailable, src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })),
navigationCount: shadow.querySelectorAll('nav, .hm-dashboard-header, [data-slot=sidebar], .hm-gnb, .hm-lnb').length,
};
});
}
async function styleIsolation(page, transport) {
const evidence = await page.evaluate(async () => {
const host = document.querySelector('.hm-orgchart-host');
const shadow = host.shadowRoot;
const sample = shadow.querySelector('.hm-orgchart-employee[data-card-kind=employee]');
const signature = () => {
const style = getComputedStyle(sample);
return { foreground: style.color, background: style.backgroundColor, font: style.fontFamily, radius: style.borderRadius, border: style.borderTopColor };
};
const initial = signature();
// Document CSS must be optional; private Shadow stylesheet links stay mounted.
const documentLinks = [...document.querySelectorAll('link[rel=stylesheet]')];
documentLinks.forEach(link => link.remove());
await new Promise(resolve => requestAnimationFrame(resolve));
const withoutTheme = signature();
for (const [property, value] of [['--hm-sem-bg-surface', '#ff0000'], ['--hm-sem-fg-primary', '#ff0000'], ['--hm-radius-container', '99px'], ['--hm-font-family-base', 'monospace']]) host.style.setProperty(property, value);
const alteredHM = signature();
const sentinel = document.createElement('span');
sentinel.textContent = 'Host style isolation sentinel';
sentinel.style.color = 'rgb(55, 66, 77)'; sentinel.style.backgroundColor = 'rgb(88, 99, 110)';
document.body.append(sentinel);
const sentinelBefore = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor };
// Public inputs intentionally remain undeclared; defaults resolve on the
// shadow shell so a host input can inherit without being masked by :host.
const token = '--hm-orgchart-bg-surface';
host.style.setProperty(token, 'rgb(7, 23, 41)');
const scopedOverride = signature();
const sentinelAfter = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor };
if (token) host.style.removeProperty(token);
for (const property of ['--hm-sem-bg-surface', '--hm-sem-fg-primary', '--hm-radius-container', '--hm-font-family-base']) host.style.removeProperty(property);
sentinel.remove();
await Promise.all(documentLinks.map(link => new Promise(resolve => {
link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', resolve, { once: true }); document.head.append(link);
})));
return { initial, withoutTheme, alteredHM, scopedOverride, token, sentinelBefore, sentinelAfter, restored: signature(), removedDocumentStyles: documentLinks.length };
});
metrics.push({ transport, stage: 'private-style-isolation', ...evidence });
check(same(evidence.initial, evidence.withoutTheme), `${transport}: Core style is unchanged without host HM Theme CSS`, evidence);
check(same(evidence.withoutTheme, evidence.alteredHM), `${transport}: unrelated host HM color/radius/font variables cannot change private OrgChart style`, evidence);
check(evidence.token && evidence.scopedOverride.background === 'rgb(7, 23, 41)' && same(evidence.sentinelBefore, evidence.sentinelAfter), `${transport}: scoped OrgChart surface override affects its instance only`, evidence);
check(same(evidence.initial, evidence.restored), `${transport}: removing the scoped override restores standalone defaults`, evidence);
}
async function standaloneFactories(page) {
const evidence = await page.evaluate(async data => {
const api = window.OrgChartP1.api;
const stylesheetUrls = window.OrgChartP1.stylesheetUrls;
const existing = document.querySelector('.hm-orgchart-host');
const originalBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor;
const results = [];
for (const [factory, selector] of [['createPositionSummary', '.hm-orgchart-position-summary'], ['createAffiliationSummary', '.hm-orgchart-affiliation-summary'], ['createStatusSummary', '.hm-orgchart-status-summary']]) {
const container = document.createElement('div'); container.style.maxWidth = '760px'; document.body.append(container);
const events = [];
const handle = api[factory]({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrl: stylesheetUrls.core, onStateChange: state => events.push(state) });
await handle.mount();
const shadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
const mounted = { state: handle.getState().status, total: handle.getState().summary?.raw.total,
modules: shadow.querySelectorAll(selector).length, nodes: shadow.querySelectorAll('.node').length,
foreign: shadow.querySelectorAll('.hm-orgchart-composition, .hm-orgchart-heading, .hm-orgchart-hub, .hm-orgchart-viewport').length,
links: [...shadow.querySelectorAll('link')].map(link => link.getAttribute('href')),
headings: shadow.textContent,
};
const zero = structuredClone(data); zero.metadata.dataVersion = `${factory}-zero`;
zero.employees = []; for (const org of zero.organizations) org.leaderId = null;
await handle.update(zero);
const currentShadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
const updated = { state: handle.getState().status, version: handle.getState().dataVersion, total: handle.getState().summary?.raw.total,
employees: currentShadow.querySelectorAll('[data-employee-id]').length,
affiliations: [...currentShadow.querySelectorAll('.hm-orgchart-affiliation-count')].map(element => element.textContent),
emptyGroups: [...currentShadow.querySelectorAll('.hm-orgchart-content-empty')].map(element => element.textContent),
};
const sibling = document.createElement('p'); sibling.textContent = 'Host-owned sibling'; container.append(sibling);
handle.destroy(); handle.destroy();
results.push({ factory, mounted, updated, eventStatuses: events.map(state => state.status),
destroyed: handle.getState().status, remaining: container.children.length, siblingPreserved: sibling.isConnected });
container.remove();
}
const container = document.createElement('div'); document.body.append(container);
const tree = api.createOrgChartTree({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrls });
await tree.mount();
const treeShadow = container.querySelector('.hm-orgchart-host').shadowRoot;
const onlyTree = { ready: tree.getState().status, nodes: treeShadow.querySelectorAll('.node').length,
employeeIDs: [...new Set([...treeShadow.querySelectorAll('[data-employee-id]')].map(element => element.dataset.employeeId))].sort(),
forbidden: treeShadow.querySelectorAll('.hm-orgchart-heading,.hm-orgchart-hub,.hm-orgchart-composition,.hm-orgchart-position-summary,.hm-orgchart-affiliation-summary,.hm-orgchart-status-summary').length,
viewport: treeShadow.querySelectorAll('.hm-orgchart-viewport').length,
};
const moved = structuredClone(data); moved.metadata.dataVersion = 'standalone-tree-updated';
moved.employees.find(employee => employee.id === 'mock-employee-01').organizationId = 'solution-sales-1';
await tree.update(moved);
const nextShadow = container.querySelector('.hm-orgchart-host').shadowRoot;
onlyTree.updatedVersion = tree.getState().dataVersion;
onlyTree.employeeMoved = !![...nextShadow.querySelectorAll('.node')].find(node => node.dataset.organizationId === 'solution-sales-1')?.querySelector('[data-employee-id="mock-employee-01"]');
tree.destroy(); onlyTree.destroyed = container.children.length === 0; container.remove();
const existingBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor;
return { summaries: results, tree: onlyTree, originalBackground, existingBackground };
}, fixture);
metrics.push({ stage: 'standalone-public-factories', ...evidence });
for (const item of evidence.summaries) {
check(item.mounted.state === 'ready' && item.mounted.total === 41 && item.mounted.modules === 1 && item.mounted.nodes === 0 && item.mounted.foreign === 0 && item.mounted.links.length === 1, `${item.factory}: public factory mounts only its own summary without engine, heading or composition`, item.mounted);
check(item.updated.state === 'ready' && item.updated.version === `${item.factory}-zero` && item.updated.total === 0 && item.updated.employees === 0
&& (item.factory !== 'createAffiliationSummary' || item.updated.affiliations.length === 7 && item.updated.affiliations.every(text => text === '0명'))
&& (item.factory !== 'createStatusSummary' || item.updated.emptyGroups.length === 2), `${item.factory}: update refreshes shared Raw Count and removes stale cards`, item.updated);
check(item.destroyed === 'destroyed' && item.remaining === 1 && item.siblingPreserved, `${item.factory}: destroy releases only owned auxiliary DOM and preserves host siblings`, item);
}
check(evidence.tree.ready === 'ready' && evidence.tree.nodes === 18 && evidence.tree.viewport === 1 && evidence.tree.forbidden === 0
&& same(evidence.tree.employeeIDs, fixture.employees.map(employee => employee.id).sort()), 'createOrgChartTree: public standalone tree preserves all source employees with no heading/hub/summary composition', evidence.tree);
check(evidence.tree.updatedVersion === 'standalone-tree-updated' && evidence.tree.employeeMoved && evidence.tree.destroyed, 'createOrgChartTree: standalone update and destroy retain engine lifecycle', evidence.tree);
check(evidence.originalBackground === evidence.existingBackground, 'standalone factories do not alter the existing full-composition instance');
}
const expectedOldIds = ['basic-organization', 'organization-hierarchy', 'employee-cards', 'organization-summary', 'json-data-update', 'loading-empty-error', 'responsive-container', 'multiple-instances'].map(id => `orgchart-content--${id}`);
const canonicalStoryIds = ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'].map(id => `orgchart--${id}`);
const internalStoryIds = [
...['business-management', 'business-planning', 'talent-growth', 'integrated-system', 'design-planning', 'solution-sales', 'top-leader', 'organization-leader', 'employee'].map(id => `orgchart-components-cards--${id}`),
...['position-summary', 'affiliation-summary', 'status-summary'].map(id => `orgchart-components-summaries--${id}`),
];
// Canonical entries cover every component once; only the three distinct employee
// kinds additionally need their internal preview. Registry preservation is static.
const renderedStoryIds = [...canonicalStoryIds, ...internalStoryIds.slice(6, 9)];
const measuredGradients = [
['#194d82', '#1d395c', 'rgb(25, 77, 130)', 'rgb(29, 57, 92)'],
['#2164a8', '#254975', 'rgb(33, 100, 168)', 'rgb(37, 73, 117)'],
['#0f6ace', '#044a94', 'rgb(15, 106, 206)', 'rgb(4, 74, 148)'],
['#8c67b6', '#5d3494', 'rgb(140, 103, 182)', 'rgb(93, 52, 148)'],
['#09a7a2', '#00847f', 'rgb(9, 167, 162)', 'rgb(0, 132, 127)'],
['#5a963c', '#3f7629', 'rgb(90, 150, 60)', 'rgb(63, 118, 41)'],
];
/** A Story metadata fix only invalidates its console checks. Preserve the full
* browser.json run and recheck those five iframes plus the newly requested
* actual manager sidebar, without repeating Core/React/summary/width checks. */
async function recheckStorybookConsole() {
const retryResults = [], retryErrors = [], rendered = [];
const checkRetry = (condition, name, detail) => retryResults.push({ name, status: condition ? 'PASS' : 'FAIL', detail });
const server = await serve(path.join(root, 'storybook-static'));
let retryBrowser;
let sidebar;
try {
retryBrowser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
const context = await retryBrowser.newContext({ viewport: { width: 1440, height: 1080 } });
const iframe = await context.newPage();
iframe.on('pageerror', error => retryErrors.push({ surface: 'iframe', type: 'pageerror', message: error.message }));
iframe.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'iframe', type: 'console', message: message.text() }); });
for (const id of canonicalStoryIds) {
await iframe.goto(`${server.origin}/iframe.html?id=${id}&viewMode=story`);
await iframe.waitForFunction(() => {
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet);
}, undefined, { timeout: 20000 });
rendered.push(id);
}
checkRetry(retryErrors.filter(error => error.surface === 'iframe').length === 0 && rendered.length === 5, 'five corrected canonical Story iframes emit no console/runtime errors', { rendered, errors: retryErrors.filter(error => error.surface === 'iframe') });
const manager = await context.newPage();
manager.on('pageerror', error => retryErrors.push({ surface: 'manager', type: 'pageerror', message: error.message }));
manager.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'manager', type: 'console', message: message.text() }); });
await manager.goto(`${server.origin}/?path=/story/orgchart--full-composition`);
await manager.waitForSelector('#storybook-explorer-tree [data-item-id="orgchart--full-composition"]', { timeout: 20000 });
sidebar = await manager.evaluate(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].filter(row => row.dataset.itemId.startsWith('orgchart') && row.getBoundingClientRect().height > 0).map(row => ({ id: row.dataset.itemId, label: row.textContent.trim() })));
const visibleStories = sidebar.filter(row => row.id.startsWith('orgchart--')).map(row => row.id).sort();
checkRetry(same(visibleStories, [...canonicalStoryIds].sort()), 'actual Storybook sidebar exposes exactly the approved five OrgChart Story entries', sidebar);
checkRetry(sidebar.every(row => !row.id.startsWith('orgchart-content') && !row.id.startsWith('orgchart-components')),
'actual default sidebar hides the original/internal reference groups while preserving their direct routes', sidebar);
await manager.screenshot({ path: path.join(output, 'storybook-sidebar.png'), fullPage: true });
checkRetry(retryErrors.filter(error => error.surface === 'manager').length === 0, 'actual Storybook manager and its preview emit no console/runtime errors', retryErrors.filter(error => error.surface === 'manager'));
} catch (error) {
retryResults.push({ name: 'selected Storybook console/sidebar recheck completion', status: 'FAIL', detail: error.stack });
} finally {
const report = { browser: retryBrowser?.version() ?? null, mode: 'storybook-console-only', preservedInitialReport: path.join(output, 'browser.json'), results: retryResults, errors: retryErrors, rendered, sidebar,
limits: ['196 previously successful Core/React/component checks were not repeated', 'This run only rechecks corrected Story metadata and newly verifies the default manager sidebar'] };
const reportPath = path.join(output, 'storybook-console-recheck.json');
await writeFile(reportPath, JSON.stringify(report, null, 2));
if (retryBrowser) await retryBrowser.close();
await new Promise(resolve => server.server.close(resolve));
const failed = retryResults.filter(result => result.status === 'FAIL');
if (failed.length) process.exitCode = 1;
console.log(JSON.stringify({ passed: retryResults.filter(result => result.status === 'PASS').length, failed, report: reportPath, sidebarScreenshot: path.join(output, 'storybook-sidebar.png') }, null, 2));
}
}
if (process.argv.includes('--storybook-console-only')) {
await recheckStorybookConsole();
process.exit(process.exitCode ?? 0);
}
let browser;
const servers = [];
try {
const productionStyles = await readFile(path.join(root, 'packages/org-chart/dist/style.css'), 'utf8');
const foreignHMVariables = [...productionStyles.matchAll(/var\(\s*(--hm-(?!orgchart-)[a-z0-9-]+)/g)].map(match => match[1]);
check(foreignHMVariables.length === 0, 'shipped Core stylesheet references only the private OrgChart namespace rather than host HM Theme variables', [...new Set(foreignHMVariables)]);
const vanilla = await serve(path.join(root, 'examples/org-chart/dist')); servers.push(vanilla);
const react = await serve(path.join(root, 'examples/org-chart-react/dist')); servers.push(react);
const storybook = await serve(path.join(root, 'storybook-static')); servers.push(storybook);
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1080 } });
const pages = new Map();
for (const [transport, origin] of [['vanilla', vanilla.origin], ['react', react.origin]]) {
const page = await context.newPage(); pages.set(transport, page); captureErrors(page, transport);
await page.goto(`${origin}/p1.html`);
await ready(page, transport, fixture.metadata.dataVersion);
const initial = await snapshot(page); snapshots.push({ transport, stage: 'initial', value: initial });
check(initial.nodes.length === 18 && expectedCounts(fixture).total === 41, `${transport}: P1 fixture contains 18 real-name organizations and 41 virtual employee IDs`);
check(initial.title === fixture.presentation.title && initial.hub === fixture.presentation.hubText, `${transport}: heading and independent hub come from JSON`);
check(initial.nodes.every(node => fixture.organizations.find(org => org.id === node.id)?.parentId === node.parentId), `${transport}: rendered hierarchy retains every original parentId`, initial.nodes.map(({ id, parentId }) => ({ id, parentId })));
const topEmployee = fixture.organizations.find(org => org.parentId === null)?.leaderId;
check(initial.topLeaders.length === 1 && initial.topLeaders[0].id === topEmployee && initial.topLeaders[0].kind === 'topLeader'
&& initial.nodes.every(node => node.employees.every(employee => employee.id !== topEmployee)), `${transport}: top leader is independent and is not duplicated in the tree`);
check(initial.nodes.filter(node => node.depth === 1).length === 6 && initial.components.includes('OrgChartTree'), `${transport}: six primary organization groups preserve the engine tree boundary`);
check(['PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => initial.components.filter(value => value === name).length === 1), `${transport}: three independent summary modules are composed once`);
checkCounts(initial, fixture, `${transport}: initial`);
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1080 });
const measurement = await measure(page); metrics.push({ transport, stage: 'initial', width, ...measurement });
check(measurement.document.scrollWidth <= measurement.document.clientWidth + 1, `${transport}: ${width}px document has no horizontal overflow`, measurement.document);
check(measurement.viewport.scrollWidth > measurement.viewport.clientWidth && measurement.viewport.overflowX === 'auto', `${transport}: ${width}px wide tree uses internal scrolling`, measurement.viewport);
check(measurement.textFits && !measurement.nodeOverlap && measurement.ids.every(item => item.whiteSpace === 'nowrap' && item.title === item.text && item.scrollHeight <= item.height + 1), `${transport}: ${width}px text bounds, node separation and one-line IDs`, { textIssues: measurement.textIssues, overlap: measurement.nodeOverlap, ids: measurement.ids });
check(measurement.cssLoaded && measurement.tokens.length >= 10 && measurement.tokens.every(item => item.value) && measurement.font && measurement.background !== 'rgba(0, 0, 0, 0)' && measurement.radius !== '0px', `${transport}: ${width}px independent OrgChart defaults and loaded Shadow styles`, { tokens: measurement.tokens, font: measurement.font, background: measurement.background, radius: measurement.radius });
check(measurement.logos.length === 7 && measurement.logos.every(item => item.available === 'true' && item.loaded && item.src.startsWith('data:image/')), `${transport}: ${width}px all seven acquired affiliation logos load`, measurement.logos.map(({ id, available, loaded }) => ({ id, available, loaded })));
check(measurement.portraitsFallback && measurement.navigationCount === 0, `${transport}: ${width}px virtual portrait fallback and content-only UI`);
await page.screenshot({ path: path.join(output, `${transport}-${width}.png`), fullPage: true });
}
await page.setViewportSize({ width: 1440, height: 1080 });
await styleIsolation(page, transport);
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' && await toggle.evaluate(element => element.getRootNode().activeElement === element), `${transport}: keyboard Enter collapses hierarchy and keeps focus`);
await toggle.press('Space');
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
check(await toggle.getAttribute('aria-expanded') === 'true', `${transport}: keyboard Space expands hierarchy`);
}
check(same(snapshots.find(item => item.transport === 'vanilla').value, snapshots.find(item => item.transport === 'react').value), 'HTML and React initial Core DOM and automatic summary snapshots are identical');
const changed = dataWith(fixture, 'p1-browser-json-v2');
const moved = changed.employees.find(employee => employee.id === 'mock-employee-01');
moved.organizationId = 'solution-sales-1'; moved.affiliationId = 'halla'; moved.workStatus = 'onLeave';
moved.name = '가상직원 긴 이름과 콘텐츠 줄바꿈 검증 01'; moved.position = '가상 장기 프로젝트 연구 및 운영 담당 수석';
moved.jobTitle = '가상 프로젝트 담당';
changed.presentation.title = 'JSON 갱신 · 가상 직원 이동 및 상태 변경';
for (const [transport, page] of pages) {
await page.locator('.hm-orgchart-summary-scroll').focus();
await setData(page, transport, changed);
check(await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.classList.contains('hm-orgchart-viewport')), `${transport}: summary keyboard focus returns to stable tree after composition regeneration`);
const current = await snapshot(page); snapshots.push({ transport, stage: 'changed', value: current });
check(current.title === changed.presentation.title && current.nodes.find(node => node.id === moved.organizationId)?.employees.some(employee => employee.id === moved.id && employee.name === moved.name && employee.position === moved.position), `${transport}: changed JSON moves and updates the same employee card`);
checkCounts(current, changed, `${transport}: changed JSON`);
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1080 });
const measurement = await measure(page); metrics.push({ transport, stage: 'long-text', width, ...measurement });
check(measurement.textFits && measurement.document.scrollWidth <= width + 1, `${transport}: ${width}px long employee name, position and job title fit with contained table scrolling`, { textIssues: measurement.textIssues, document: measurement.document, summary: measurement.summary });
}
check(current.statuses.find(group => group.status === 'onLeave').employees.some(employee => employee.id === moved.id) && !current.statuses.find(group => group.status === 'dispatched').employees.some(employee => employee.id === moved.id), `${transport}: state groups update without stale employee cards`);
// Flags select independent components rather than alter the employee source or counts.
const cases = [
['hide-position', { showSummary: false }, { position: false, affiliations: true, dispatched: true, onLeave: true }],
['hide-affiliation', { showAffiliations: false }, { position: true, affiliations: false, dispatched: true, onLeave: true }],
['hide-dispatched', { showDispatched: false }, { position: true, affiliations: true, dispatched: false, onLeave: true }],
['hide-onleave', { showOnLeave: false }, { position: true, affiliations: true, dispatched: true, onLeave: false }],
['hide-both-status', { showDispatched: false, showOnLeave: false }, { position: true, affiliations: true, dispatched: false, onLeave: false }],
];
for (const [name, flags, expected] of cases) {
const data = dataWith(changed, `${transport}-${name}`); Object.assign(data.presentation.groups, flags);
await setData(page, transport, data);
const current = await snapshot(page);
check(!!current.positions === expected.position && !!current.affiliations.length === expected.affiliations
&& current.statuses.some(group => group.status === 'dispatched') === expected.dispatched
&& current.statuses.some(group => group.status === 'onLeave') === expected.onLeave
&& current.components.includes('StatusSummary') === (expected.dispatched || expected.onLeave), `${transport}: ${name} changes only its independent component`, current.components);
check(!current.positions || Number(current.positions.totals.at(-2)) === 41, `${transport}: ${name} cannot alter Raw Count`);
}
const zeroAffiliation = dataWith(changed, `${transport}-zero-affiliation`);
for (const employee of zeroAffiliation.employees) if (employee.affiliationId === 'baron') employee.affiliationId = 'hanmac';
await setData(page, transport, zeroAffiliation);
check((await snapshot(page)).affiliations.find(item => item.id === 'baron')?.count === '0명', `${transport}: zero-count declared affiliations stay visible`);
const zeroStatus = dataWith(changed, `${transport}-zero-status`);
for (const employee of zeroStatus.employees) employee.workStatus = 'active';
await setData(page, transport, zeroStatus);
check((await snapshot(page)).statuses.every(group => group.employees.length === 0 && group.empty && group.heading.includes('0명')), `${transport}: enabled empty status groups have explicit empty content`);
// A successful HTTP response containing non-image bytes triggers img.error,
// avoiding expected network-console failures while testing the actual fallback.
await page.route('**/p1-invalid-portrait.png', route => route.fulfill({ status: 200, contentType: 'image/png', body: 'not a png' }));
const brokenImage = dataWith(changed, `${transport}-image-fallback`);
brokenImage.employees.find(employee => employee.id === moved.id).profileImage = './p1-invalid-portrait.png';
await setData(page, transport, brokenImage);
const brokenCards = page.locator(`.hm-orgchart-employee[data-employee-id="${moved.id}"]`);
for (let index = 0; index < await brokenCards.count(); index++) await brokenCards.nth(index).scrollIntoViewIfNeeded();
await page.waitForFunction(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => !card.querySelector('.hm-orgchart-avatar img')), moved.id);
check(await page.evaluate(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => card.querySelector('.hm-orgchart-avatar').textContent.trim()), moved.id), `${transport}: invalid profile images safely return to initials`);
await page.unroute('**/p1-invalid-portrait.png');
await setData(page, transport, dataWith(changed, 'p1-browser-final-changed'));
snapshots.push({ transport, stage: 'restored-changed', value: await snapshot(page) });
check(transport !== 'react' || await page.evaluate(() => window.OrgChartP1React.errors.length === 0), `${transport}: auxiliary regeneration has no lifecycle error`);
}
check(same(...snapshots.filter(item => item.stage === 'restored-changed').map(item => item.value)), 'HTML and React Core snapshots remain identical after JSON and independent visibility updates');
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
const old = Object.values(index.entries).filter(entry => entry.title === 'OrgChart/Content' && entry.type === 'story');
check(old.length === 8 && same(old.map(entry => entry.id).sort(), [...expectedOldIds].sort()) && old.every(entry => entry.tags.includes('internal')), 'all original eight Storybook entries remain registered as internal without re-running their baseline suite', old.map(entry => entry.id));
check(internalStoryIds.every(id => index.entries[id]?.type === 'story' && index.entries[id].tags.includes('internal')), 'twelve isolated card/summary Stories are preserved as internal references', internalStoryIds);
const visible = Object.values(index.entries).filter(entry => entry.title === 'OrgChart' && entry.type === 'story' && !entry.tags.includes('internal')).map(entry => entry.id).sort();
check(same(visible, [...canonicalStoryIds].sort()), 'canonical OrgChart offers exactly the five approved user-visible entries', visible);
const sb = await context.newPage(); captureErrors(sb, 'storybook');
for (const id of renderedStoryIds) {
if (!index.entries[id]) continue;
await sb.goto(`${storybook.origin}/iframe.html?id=${id}&viewMode=story`);
await sb.waitForFunction(() => {
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host');
const shadow = host?.shadowRoot;
return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet);
}, undefined, { timeout: 20000 });
await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-affiliation-mark img')].every(image => image.complete));
const component = await sb.evaluate(() => {
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'), shadow = host.shadowRoot;
const organizationName = shadow.querySelector('.hm-orgchart-organization-name');
const node = shadow.querySelector('.node');
return { components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component), nodeCount: shadow.querySelectorAll('.node').length,
employees: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, kind: card.dataset.cardKind })),
gradients: organizationName ? { background: getComputedStyle(organizationName).backgroundImage, variable: getComputedStyle(node).getPropertyValue('--hm-orgchart-group-background').trim() } : null,
body: document.body.textContent, title: organizationName?.textContent,
hasPosition: !!shadow.querySelector('.hm-orgchart-position-summary'), hasAffiliation: !!shadow.querySelector('.hm-orgchart-affiliation-summary'), hasStatus: !!shadow.querySelector('.hm-orgchart-status-summary'),
linksLoaded: [...shadow.querySelectorAll('link')].every(link => link.sheet),
};
});
snapshots.push({ transport: 'storybook', stage: id, value: component });
check(component.linksLoaded, `${id}: real Core preview and Shadow CSS load`);
if (id === 'orgchart--position-summary' || id === 'orgchart--affiliation-summary') {
const kind = id.endsWith('position-summary') ? 'PositionSummary' : id.endsWith('affiliation-summary') ? 'AffiliationSummary' : 'StatusSummary';
check(component.nodeCount === 0 && same(component.components, [kind])
&& component.hasPosition === (kind === 'PositionSummary') && component.hasAffiliation === (kind === 'AffiliationSummary') && component.hasStatus === (kind === 'StatusSummary'), `${id}: standalone summary has exactly its Core module and no engine tree`, component.components);
} else if (id.endsWith('full-composition')) {
check(component.nodeCount === 18 && ['OrgChartTree', 'PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => component.components.includes(name)), `${id}: composition contains shared tree plus all independent summaries`);
for (const width of [390, 768, 1280, 1440]) {
await sb.setViewportSize({ width, height: 1080 });
const measurement = await measure(sb); metrics.push({ transport: 'storybook', stage: id, width, ...measurement });
check(measurement.document.scrollWidth <= width + 1 && measurement.textFits && measurement.navigationCount === 0, `${id}: ${width}px content composition has no document overflow or clipped text`, { document: measurement.document, textIssues: measurement.textIssues });
await sb.screenshot({ path: path.join(output, `${id}-${width}.png`), fullPage: true });
}
await sb.setViewportSize({ width: 1440, height: 1080 });
await sb.getByRole('button', { name: 'PositionSummary 숨기기', exact: true }).click();
await sb.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-position-summary'));
check(await sb.locator('.hm-orgchart-affiliation-summary').count() === 1 && await sb.locator('.hm-orgchart-status-summary').count() === 1, `${id}: Story button independently hides PositionSummary`);
await sb.getByRole('button', { name: '가상 JSON v2 반영', exact: true }).click();
await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('[data-work-status=onLeave] [data-employee-id]')].some(element => element.dataset.employeeId === 'mock-employee-01'));
check(await sb.locator('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total').textContent() === 'Raw Count · 전체 41명', `${id}: Story JSON update reuses 41 unique virtual employees`);
} else if (id.endsWith('top-leader') || id.endsWith('organization-leader') || id.endsWith('--employee')) {
const kind = id.endsWith('top-leader') ? 'topLeader' : id.endsWith('organization-leader') ? 'leader' : 'employee';
check(component.nodeCount === 0 && component.employees.length === 1 && component.employees[0].kind === kind && same(component.components, ['EmployeeCard']), `${id}: standalone ${kind} uses one actual Core employee card`, component.employees);
} else if (id === 'orgchart--department-colors') {
await sb.waitForFunction(() => {
const values = [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-value')];
return values.length === 30 && values.every(element => element.textContent.trim());
});
const colors = await sb.evaluate(() => [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-preview[data-group-variant]')].map(row => ({
variant: Number(row.dataset.groupVariant),
text: row.textContent,
background: getComputedStyle(row.querySelector('[data-color-variable$="-gradient"]')).backgroundImage,
variable: getComputedStyle(row).getPropertyValue('--hm-orgchart-group-background').trim(),
declarations: [...row.querySelectorAll('[data-color-variable]')].map(element => ({ variable: element.dataset.colorVariable, property: element.dataset.cssProperty, applied: getComputedStyle(element).getPropertyValue(element.dataset.cssProperty), valueText: element.parentElement.querySelector('.hm-orgchart-color-value')?.textContent })),
card: (() => { const card = row.querySelector('.hm-orgchart-employee'); const style = getComputedStyle(card); return { background: style.backgroundColor, color: style.color, border: style.borderTopColor }; })(),
})));
check(colors.length === 6, `${id}: all six department color components are rendered`, colors);
for (const color of colors) {
const [start, end, rgbStart, rgbEnd] = measuredGradients[color.variant];
check(color.background.includes('linear-gradient') && color.background.includes(rgbStart) && color.background.includes(rgbEnd)
&& color.variable === `linear-gradient(180deg, ${start} 0%, ${end} 100%)`
&& color.text.includes(start) && color.text.includes(end) && color.declarations.length === 5
&& color.declarations.every(item => item.variable.startsWith('--hm-orgchart-') && item.valueText?.includes('#')), `${id}: variant ${color.variant} applies exact measured gradient and documents HEX/CSS variables`, color);
check(color.declarations.find(item => item.variable.endsWith('-card-background')).applied === color.card.background
&& color.declarations.find(item => item.variable.endsWith('-card-color')).applied === color.card.color
&& color.declarations.find(item => item.variable.endsWith('-border')).applied === color.card.border, `${id}: variant ${color.variant} legend values match the actual card styles`, color.card);
}
} else if (id === 'orgchart--logo') {
const logos = await sb.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot;
return { marks: [...shadow.querySelectorAll('.hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, label: mark.getAttribute('aria-label'), src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })), text: shadow.textContent,
counts: shadow.querySelectorAll('.hm-orgchart-affiliation-count, .hm-orgchart-raw-total, .hm-orgchart-position-table').length };
});
check(component.nodeCount === 0 && component.components.includes('Logo') && logos.marks.length === 7 && logos.marks.every(mark => mark.loaded && mark.src.startsWith('data:image/') && mark.label)
&& fixture.affiliations.every(affiliation => logos.text.includes(affiliation.name)) && logos.counts === 0, `${id}: standalone Logo uses all seven named assets without statistics`, logos.marks.map(({ id, label, loaded }) => ({ id, label, loaded })));
}
}
await standaloneFactories(pages.get('vanilla'));
await pages.get('vanilla').evaluate(() => window.OrgChartP1.chart.destroy());
check(await pages.get('vanilla').locator('.hm-orgchart-host').count() === 0, 'destroy releases composed tree and all auxiliary summary DOM');
check(await pages.get('react').evaluate(() => window.OrgChartP1React.errors.length === 0), 'React wrapper lifecycle error boundary remains clear');
check(errors.length === 0, 'targeted P1 Vanilla, React and canonical/employee Stories have no console/runtime errors', errors);
} catch (error) {
results.push({ name: 'P1 browser harness completion', status: 'FAIL', detail: error.stack });
} finally {
const report = { browser: browser?.version() ?? null, results, errors, metrics, snapshots,
limits: ['Figma Dev Mode detailed properties NOT VERIFIED', 'Workdesk pixel parity NOT VERIFIED; gradient checks verify the recorded measured header sources only', 'Screen-reader announcements and heap/GC behavior NOT VERIFIED', 'Original 69/79 browser suites deliberately not repeated'] };
await writeFile(path.join(output, 'browser.json'), JSON.stringify(report, null, 2));
if (browser) await browser.close();
for (const { server } of servers) await new Promise(resolve => server.close(resolve));
const failed = results.filter(result => result.status === 'FAIL');
if (failed.length) process.exitCode = 1;
console.log(JSON.stringify({ passed: results.filter(result => result.status === 'PASS').length, failed, report: path.join(output, 'browser.json') }, null, 2));
}
+338
View File
@@ -0,0 +1,338 @@
import http from 'node:http';
import path from 'node:path';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
// P2-only browser evidence. Existing v0/P1 expectations and reports are retained.
// ESM HTML and React use HTTP; the classic HTML is opened directly with file://.
// No Chrome file-access flag is used, so classic portability is tested honestly.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p2');
await mkdir(output, { recursive: true });
const fixturePath = path.join(root, 'examples/org-chart/data-p1.json');
const fixtureBytes = await readFile(fixturePath);
const fixtureHash = createHash('sha256').update(fixtureBytes).digest('hex');
const fixture = JSON.parse(fixtureBytes.toString('utf8'));
const bundledPlaywright = 'C:\\Users\\user\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\node\\node_modules\\playwright\\index.mjs';
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE ?? bundledPlaywright).href);
const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }];
const expectedArrows = [[194.15, 311.75, 66.25, 19.8], [430.4, 311.75, 66.25, 19.8], [666.65, 302, 183.75, 42], [1044, 302, 258.92, 42], [1520.55, 305, 135.35, 36]];
const results = [], measurements = [], errors = [], screenshots = [], servers = [];
const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const near = (actual, expected, tolerance = 0.2) => Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance;
const spacedKorean = text => text.replace(/([가-힣])(?=[가-힣])/g, '$1 ');
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' };
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(directory, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
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));
servers.push(server);
return `http://127.0.0.1:${server.address().port}`;
}
function captureErrors(page, route) {
page.on('pageerror', error => errors.push({ route, type: 'pageerror', message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ route, type: 'console', message: message.text() }); });
}
async function ready(page, mode = 'readOnly') {
await page.waitForFunction(expectedMode => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return shadow?.querySelector(`.hm-orgchart-composition[data-display-mode="${expectedMode}"] [data-card-asset]`)
&& !shadow.querySelector('.hm-orgchart-state')?.textContent
&& [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet);
}, mode, { timeout: 20000 });
await page.evaluate(async () => {
await document.fonts.ready;
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
await Promise.all([...shadow.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
}
async function measure(page) {
return page.evaluate(async () => {
const host = document.querySelector('.hm-orgchart-host');
const shadow = host.shadowRoot;
const composition = shadow.querySelector('.hm-orgchart-composition');
const stage = shadow.querySelector('.hm-orgchart-stage');
const origin = stage.getBoundingClientRect();
// dataset.scale is rounded to four digits; measured transform is required
// to recover the small cards accurately at the 390px viewport.
const scale = origin.width / stage.offsetWidth;
const rect = element => {
const box = element.getBoundingClientRect();
return { x: (box.left - origin.left) / scale, y: (box.top - origin.top) / scale, width: box.width / scale, height: box.height / scale };
};
const dimensions = element => element ? { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY } : null;
const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
const overlaps = (a, b) => Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.75 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.75;
const fingerprint = source => {
let hash = 2166136261;
for (let index = 0; index < source.length; index++) hash = Math.imul(hash ^ source.charCodeAt(index), 16777619);
return (hash >>> 0).toString(16);
};
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee[data-card-asset]')].map(card => {
const node = card.closest('.node[data-organization-id]');
const background = card.querySelector('.hm-orgchart-card-background');
const css = getComputedStyle(card);
const personName = card.querySelector('.hm-orgchart-person-name');
const cardBox = rect(card), avatarBox = rect(card.querySelector('.hm-orgchart-avatar'));
const logoBox = card.querySelector('.hm-orgchart-affiliation-mark') ? rect(card.querySelector('.hm-orgchart-affiliation-mark')) : null;
const contentIssues = [], textBoxes = [], fontRangeOverlaps = [];
const personText = card.querySelector('.hm-orgchart-person-text');
const positionLine = personText?.firstElementChild;
const lineBoxes = { position: positionLine ? rect(positionLine) : null, name: rect(personName) };
const role = card.querySelector('.hm-orgchart-person-role');
const badge = card.querySelector('.hm-orgchart-position-row .hm-orgchart-job-title');
const horizontalRoleBadgeGap = badge ? rect(badge).x - rect(role).x - rect(role).width : null;
if (lineBoxes.position && lineBoxes.position.y + lineBoxes.position.height > lineBoxes.name.y + 0.3) contentIssues.push('position/name: actual line-box overlap');
if (horizontalRoleBadgeGap !== null && horizontalRoleBadgeGap < -0.3) contentIssues.push('role/badge: horizontal element-box overlap');
const contained = box => box.x >= cardBox.x - 0.5 && box.y >= cardBox.y - 0.5 && box.x + box.width <= cardBox.x + cardBox.width + 0.5 && box.y + box.height <= cardBox.y + cardBox.height + 0.5;
for (const element of card.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title')) {
if (element.clientWidth > 0 && element.scrollWidth > element.clientWidth + 1) contentIssues.push(`${element.className}: horizontal text overflow`);
const range = document.createRange(); range.selectNodeContents(element);
for (const box of range.getClientRects()) {
const textBox = { x: (box.left - origin.left) / scale, y: (box.top - origin.top) / scale, width: box.width / scale, height: box.height / scale };
if (!contained(textBox)) contentIssues.push(`${element.className}: text outside card`);
if (overlaps(textBox, avatarBox)) contentIssues.push(`${element.className}: avatar overlap`);
if (logoBox && overlaps(textBox, logoBox)) contentIssues.push(`${element.className}: logo overlap`);
textBoxes.push({ className: element.className, box: textBox });
}
}
// Range rectangles contain the font ascent/descent envelope, including
// unpainted space. Noto Sans KR can make adjacent 0-gap line envelopes
// intersect despite distinct element line boxes and non-overlapping ink.
// Retain these metrics for visual review; never classify them as glyph
// collisions or silently claim that screenshot pixels were verified.
for (const [index, a] of textBoxes.entries()) for (const b of textBoxes.slice(index + 1)) if (a.className !== b.className && overlaps(a.box, b.box)) fontRangeOverlaps.push({ a: a.className, b: b.className, horizontal: Math.min(a.box.x + a.box.width, b.box.x + b.box.width) - Math.max(a.box.x, b.box.x), vertical: Math.min(a.box.y + a.box.height, b.box.y + b.box.height) - Math.max(a.box.y, b.box.y) });
if (!contained(avatarBox)) contentIssues.push('avatar outside card');
if (logoBox && !contained(logoBox)) contentIssues.push('logo outside card');
return {
employeeId: card.dataset.employeeId, organizationId: node?.dataset.organizationId ?? null,
depth: node ? Number(node.dataset.organizationDepth ?? node.dataset.depth) : null,
kind: card.dataset.cardKind, asset: card.dataset.cardAsset, size: card.dataset.cardAssetSize,
topSlot: !!card.closest('.hm-orgchart-top-leaders'), box: rect(card),
naturalWidth: background?.naturalWidth ?? 0, naturalHeight: background?.naturalHeight ?? 0,
embedded: !!background?.src.startsWith('data:image/svg+xml'), loaded: !!background?.complete && background.naturalWidth > 0,
name: text(personName), role: text(card.querySelector('.hm-orgchart-person-role')), badge: text(card.querySelector('.hm-orgchart-job-title')),
nameAccessible: personName.getAttribute('aria-label'), roleAccessible: card.querySelector('.hm-orgchart-person-role')?.getAttribute('aria-label'), badgeAccessible: card.querySelector('.hm-orgchart-job-title')?.getAttribute('aria-label'),
nameStyle: { fontSize: getComputedStyle(personName).fontSize, fontWeight: getComputedStyle(personName).fontWeight, textOverflow: getComputedStyle(personName).textOverflow },
contentIssues, textBoxes, lineBoxes, horizontalRoleBadgeGap, fontRangeOverlaps,
radius: css.borderRadius, border: css.borderWidth, fill: css.backgroundColor,
};
});
const headers = [...shadow.querySelectorAll('.node[data-organization-id] .hm-orgchart-organization-name')].map(header => {
const node = header.closest('.node');
const card = node.querySelector('.hm-orgchart-employee');
const box = rect(header);
const wrapperCss = getComputedStyle(header.parentElement);
return { organizationId: node.dataset.organizationId, depth: Number(node.dataset.organizationDepth ?? node.dataset.depth), text: text(header), box,
firstCardKind: card?.dataset.cardKind ?? null, headerCardGap: card ? rect(card).y - box.y - box.height : null,
wrapperBorder: wrapperCss.borderWidth, wrapperFill: wrapperCss.backgroundColor, wrapperShadow: wrapperCss.boxShadow };
});
const overlay = shadow.querySelector('.hm-orgchart-workdesk-overlay');
const arrows = await Promise.all([...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay image[data-arrow]')].map(async element => {
const source = element.getAttribute('href') ?? element.getAttributeNS('http://www.w3.org/1999/xlink', 'href') ?? '';
const image = new Image(); image.src = source;
await image.decode().catch(() => {});
return { id: element.dataset.arrow, x: Number(element.getAttribute('x')), y: Number(element.getAttribute('y')), width: Number(element.getAttribute('width')), height: Number(element.getAttribute('height')),
box: rect(element), embedded: source.startsWith('data:image/png'), fingerprint: fingerprint(source), loaded: image.complete && image.naturalWidth > 0, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight,
preserveAspectRatio: element.getAttribute('preserveAspectRatio') };
}));
const decorations = [...shadow.querySelectorAll('img[data-workdesk-asset]')].map(image => ({ asset: image.dataset.workdeskAsset, box: rect(image), embedded: image.src.startsWith('data:image/'), fingerprint: fingerprint(image.src), loaded: image.complete && image.naturalWidth > 0, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight }));
const connectors = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay path[data-connector]')].map(element => ({ id: element.dataset.connector, path: element.getAttribute('d'), stroke: getComputedStyle(element).stroke, strokeWidth: getComputedStyle(element).strokeWidth, dasharray: getComputedStyle(element).strokeDasharray, fill: getComputedStyle(element).fill, box: rect(element) }));
const physical = [...cards.map(card => ({ type: 'card', id: card.employeeId, box: card.box })), ...headers.map(header => ({ type: 'header', id: header.organizationId, box: header.box }))];
const collisions = [];
for (const [index, a] of physical.entries()) for (const b of physical.slice(index + 1)) if (overlaps(a.box, b.box)) collisions.push({ a: `${a.type}:${a.id}`, b: `${b.type}:${b.id}` });
const arrowCollisions = arrows.flatMap(arrow => physical.filter(item => overlaps(arrow.box, item.box)).map(item => ({ arrow: arrow.id, item: `${item.type}:${item.id}` })));
const summaries = [...shadow.querySelectorAll('.hm-orgchart-organization-totals, .hm-orgchart-affiliation-bar')].map(element => ({ className: element.className, box: rect(element), text: text(element) }));
const summaryCollisions = summaries.flatMap(summary => physical.filter(item => overlaps(summary.box, item.box)).map(item => ({ summary: summary.className, item: `${item.type}:${item.id}` })));
const pseudo = selector => [...shadow.querySelectorAll(selector)].map(element => {
const before = getComputedStyle(element, '::before'), after = getComputedStyle(element, '::after');
return { className: element.className, before: { content: before.content, display: before.display }, after: { content: after.content, display: after.display } };
});
return {
scale, recordedScale: composition.dataset.scale, baseWidth: origin.width / scale, baseHeight: origin.height / scale,
stageTransform: getComputedStyle(stage).transform, displayMode: composition.dataset.displayMode,
document: dimensions(document.documentElement), viewport: dimensions(shadow.querySelector('.hm-orgchart-viewport')),
cards, headers, arrows, decorations, connectors, overlayViewBox: overlay?.getAttribute('viewBox') ?? null,
presentationNames: [...shadow.querySelectorAll('.hm-orgchart-employee .hm-orgchart-person-name')].map(element => ({ text: text(element), accessible: element.getAttribute('aria-label'), overflow: element.scrollWidth > element.clientWidth })),
presentationPeople: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ employeeId: card.dataset.employeeId,
role: text(card.querySelector('.hm-orgchart-person-role')), roleAccessible: card.querySelector('.hm-orgchart-person-role')?.getAttribute('aria-label'),
badge: text(card.querySelector('.hm-orgchart-job-title')), badgeAccessible: card.querySelector('.hm-orgchart-job-title')?.getAttribute('aria-label') })),
collisions, arrowCollisions, summaryCollisions, summaries,
debugCount: shadow.querySelectorAll('.hm-orgchart-toggle, .hm-orgchart-kind, .hm-orgchart-domain-id, .hm-orgchart-node-count, .hm-orgchart-vacancy, .hm-orgchart-description, .hm-orgchart-policy').length,
hostControls: document.querySelectorAll('.hm-orgchart-story-controls').length,
largeMatrixCount: shadow.querySelectorAll('.hm-orgchart-position-table, .hm-orgchart-position-summary').length,
totalsCount: shadow.querySelectorAll('.hm-orgchart-organization-totals').length,
affiliationBarCount: shadow.querySelectorAll('.hm-orgchart-affiliation-bar').length,
affiliationBarRows: [...shadow.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(element => rect(element).y),
affiliationTotal: text(shadow.querySelector('.hm-orgchart-affiliation-bar-total dd')),
statusGroups: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(element => ({ status: element.dataset.workStatus, text: text(element.querySelector('h3')), box: rect(element), border: getComputedStyle(element).borderWidth })),
originalBranchPseudos: pseudo('.hm-orgchart-axis, .hm-orgchart-hub, .hm-orgchart-forest, .hm-orgchart-tree'),
};
});
}
function verify(value, label, viewport) {
const top = value.cards.filter(card => card.kind === 'topLeader');
const depth1 = value.cards.filter(card => card.kind === 'leader' && card.depth === 1);
const deeper = value.cards.filter(card => card.kind === 'leader' && card.depth >= 2);
const sizeMatches = (card, size, width, height) => card.size === size && near(card.box.width, width) && near(card.box.height, height) && card.naturalWidth === width && card.naturalHeight === height;
check(top.length === 1 && top[0].topSlot && sizeMatches(top[0], 'representative', 146, 74), `${label}: representative alone uses 146×74 card`);
check(depth1.length === 6 && depth1.every(card => sizeMatches(card, 'leader', 146, 68)), `${label}: all six actual Depth 1 leaders use 146×68 cards`, depth1.map(card => ({ id: card.employeeId, size: card.size, box: card.box })));
check(deeper.length > 0 && deeper.every(card => sizeMatches(card, 'employee', 108, 52)), `${label}: every actual Depth ≥2 leader uses 108×52 card`, deeper.map(card => ({ id: card.employeeId, depth: card.depth, size: card.size, box: card.box })));
check(deeper.every(card => spacedKorean(fixture.employees.find(employee => employee.id === card.employeeId)?.jobTitle ?? '') === card.badge && card.badge), `${label}: small leader cards preserve supplied job-title badges with requested Korean spacing`, deeper.map(card => ({ id: card.employeeId, badge: card.badge })));
check(value.presentationNames.length > 0 && value.presentationNames.every(name => name.text === '홍 길 동' && name.accessible === '홍길동' && !name.overflow), `${label}: every displayed person name is 홍 길 동, fits, and retains an unspaced accessible name`, value.presentationNames);
check(value.presentationPeople.every(card => {
const employee = fixture.employees.find(employee => employee.id === card.employeeId);
return employee && card.role === spacedKorean(employee.position) && card.roleAccessible === employee.position && card.badge === spacedKorean(employee.jobTitle ?? '') && (!employee.jobTitle || card.badgeAccessible === employee.jobTitle);
}), `${label}: positions and job-title badges use one normal inter-Korean space while accessible labels retain original text`);
check(value.cards.every(card => card.contentIssues.length === 0), `${label}: text stays within card/avatar/logo boundaries; actual line boxes and horizontal role/badge boxes do not overlap`, value.cards.filter(card => card.contentIssues.length).map(card => ({ id: card.employeeId, issues: card.contentIssues, textBoxes: card.textBoxes, lineBoxes: card.lineBoxes, horizontalRoleBadgeGap: card.horizontalRoleBadgeGap })));
check(value.cards.every(card => card.loaded && card.embedded), `${label}: original embedded card SVG assets load`);
const leaders = value.headers.filter(header => header.firstCardKind === 'leader');
check(leaders.filter(header => header.depth === 1).length === 6 && leaders.every(header => near(header.headerCardGap, header.depth === 1 ? 8 : 10, 0.3)), `${label}: header-to-leader boundingRect gap is 8px at Depth 1 and 10px below`, leaders.map(header => ({ id: header.organizationId, depth: header.depth, gap: header.headerCardGap })));
check(value.displayMode === 'readOnly' && value.debugCount === 0 && value.hostControls === 0, `${label}: read-only composition has no collapse/test/debug content`);
check(value.headers.every(header => header.wrapperBorder === '0px' && header.wrapperFill === 'rgba(0, 0, 0, 0)' && header.wrapperShadow === 'none'), `${label}: organization groups retain unframed presentation`);
check(value.totalsCount === 1 && value.affiliationBarCount === 1 && value.largeMatrixCount === 0, `${label}: compact organization totals and untitled affiliation bar are retained`);
check(value.affiliationTotal === `${new Set(fixture.employees.map(employee => employee.id)).size}명` && Math.max(...value.affiliationBarRows) - Math.min(...value.affiliationBarRows) < 1, `${label}: affiliation counts retain one base-canvas row and fixture total`);
check(value.statusGroups.length === 2 && value.statusGroups.every(group => group.border === '0px' && !group.text.includes('·')), `${label}: dispatched/on-leave groups retain compact presentation`);
check(near(value.baseWidth, 1920, 0.3) && near(value.baseHeight, 914.5, 1) && value.scale > 0 && value.scale <= 1, `${label}: entire 1920×914.5 base stage scales uniformly`, { width: value.baseWidth, height: value.baseHeight, scale: value.scale });
check(value.document.scrollWidth <= viewport.width + 1, `${label}: document has no horizontal overflow`, value.document);
check(value.overlayViewBox?.replace(/\s+/g, ' ').trim() === '0 0 1920 914.5', `${label}: connectors and arrows share stage coordinates`);
check(value.arrows.length === 5 && value.arrows.every((arrow, index) => [arrow.x, arrow.y, arrow.width, arrow.height].every((number, coordinate) => near(number, expectedArrows[index][coordinate], 0.05)) && arrow.preserveAspectRatio === 'none' && arrow.embedded && arrow.loaded), `${label}: five embedded collaboration arrows retain source placement`, value.arrows);
check(value.decorations.length === 5 && value.decorations.every(asset => asset.embedded && asset.loaded), `${label}: five Workdesk decorations load without external URLs`, value.decorations);
check(value.connectors.length === 5 && value.connectors.some(connector => /[Cc]/.test(connector.path ?? '')) && value.connectors.every(connector => connector.path && connector.fill === 'none' && connector.stroke === 'rgb(69, 99, 159)' && near(parseFloat(connector.strokeWidth), 0.893, 0.03) && connector.dasharray.split(/[ ,]+/).every(number => near(parseFloat(number), 1.79, 0.03))), `${label}: source overlay curves retain stroke, fill, width and dash pattern`, value.connectors);
check(value.collisions.length === 0, `${label}: card/header rectangles do not overlap`, value.collisions);
check(value.arrowCollisions.length === 0, `${label}: collaboration arrow rectangles do not intersect cards/headers`, value.arrowCollisions);
check(value.summaryCollisions.length === 0, `${label}: compact summary rectangles do not intersect cards/headers`, value.summaryCollisions);
}
async function tooltip(page, transport) {
const lowerOrganization = fixture.organizations.find(organization => organization.parentId && fixture.organizations.find(parent => parent.id === organization.parentId)?.parentId && organization.leaderId);
const employeeId = lowerOrganization.leaderId;
const fullName = '홍 길 동';
const name = page.locator(`.hm-orgchart-employee[data-employee-id="${employeeId}"] .hm-orgchart-person-name`);
await name.waitFor({ state: 'visible' });
// All requested default names must fit. Induce a narrow name box locally to
// exercise overflow behavior without altering fixture or presentation text.
const originalInlineStyle = await name.getAttribute('style');
await name.evaluate(element => { element.style.width = '8px'; element.style.maxWidth = '8px'; });
await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`)?.dataset.nameOverflow === 'true', employeeId);
const before = await page.evaluate(id => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const name = shadow.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`);
const tip = shadow.getElementById(name.getAttribute('aria-describedby'));
return { fullText: name.textContent, tabIndex: name.tabIndex, ellipsis: getComputedStyle(name).textOverflow, tipHidden: tip.hidden, documentHeight: document.documentElement.scrollHeight, viewportHeight: shadow.querySelector('.hm-orgchart-viewport').scrollHeight };
}, employeeId);
check(before.fullText === fullName && before.tabIndex === 0 && before.ellipsis === 'ellipsis' && before.tipHidden, `${transport}: induced overflow preserves 홍 길 동 and exposes focusable ellipsis`, before);
await name.hover();
const tip = page.locator('.hm-orgchart-person-name-tooltip:not([hidden])');
await tip.waitFor({ state: 'visible' });
check(await tip.textContent() === fullName, `${transport}: hover exposes complete requested name in tooltip`);
await tip.hover();
check(await tip.isVisible(), `${transport}: tooltip remains hoverable`);
await page.mouse.move(0, 0);
await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('[role=tooltip]')].every(tip => tip.hidden));
// The only overflowed name is in the normal tab order: use the keyboard,
// rather than locator.focus(), to prove actual Tab activation.
await page.evaluate(() => document.activeElement?.blur());
let reached = false;
for (let step = 0; step < 100 && !reached; step++) {
await page.keyboard.press('Tab');
reached = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.closest(`[data-employee-id="${id}"]`)?.dataset.employeeId === id, employeeId);
}
check(reached && await tip.isVisible(), `${transport}: keyboard Tab focus opens complete tooltip`);
await page.keyboard.press('Escape');
check(!(await tip.isVisible()), `${transport}: Escape dismisses tooltip while focus stays on name`);
const after = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
return { documentHeight: document.documentElement.scrollHeight, viewportHeight: shadow.querySelector('.hm-orgchart-viewport').scrollHeight, activeClass: shadow.activeElement?.className };
});
check(before.documentHeight === after.documentHeight && before.viewportHeight === after.viewportHeight && after.activeClass === 'hm-orgchart-person-name', `${transport}: hidden tooltip does not inflate scroll extents or lose focus`, { before, after });
await page.keyboard.press('Tab');
check(!(await tip.isVisible()), `${transport}: moving focus keeps tooltip dismissed`);
await name.evaluate((element, originalInlineStyle) => originalInlineStyle === null ? element.removeAttribute('style') : element.setAttribute('style', originalInlineStyle), originalInlineStyle);
await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`)?.dataset.nameOverflow === 'false', employeeId);
check(await name.getAttribute('tabindex') === null && await name.getAttribute('aria-describedby') === null, `${transport}: restoring the name box removes overflow-only focus and tooltip attributes`);
}
function sameGeometry(a, b) {
if (a.cards.length !== b.cards.length || a.headers.length !== b.headers.length) return false;
const boxesEqual = (first, second) => ['x', 'y', 'width', 'height'].every(key => near(first[key], second[key], 0.4));
return a.cards.every((card, index) => {
const other = b.cards[index];
return ['employeeId', 'organizationId', 'depth', 'kind', 'asset', 'size', 'badge', 'name', 'role'].every(key => card[key] === other[key]) && boxesEqual(card.box, other.box);
}) && a.headers.every((header, index) => header.organizationId === b.headers[index].organizationId && boxesEqual(header.box, b.headers[index].box))
&& JSON.stringify(a.connectors.map(({ id, path }) => ({ id, path }))) === JSON.stringify(b.connectors.map(({ id, path }) => ({ id, path })))
&& JSON.stringify(a.arrows.map(({ fingerprint, x, y, width, height }) => ({ fingerprint, x, y, width, height }))) === JSON.stringify(b.arrows.map(({ fingerprint, x, y, width, height }) => ({ fingerprint, x, y, width, height })));
}
let browser;
try {
const [htmlOrigin, reactOrigin] = await Promise.all([serve(path.join(root, 'examples/org-chart/dist')), serve(path.join(root, 'examples/org-chart-react/dist'))]);
const storybookOrigin = process.env.ORGCHART_STORYBOOK_URL?.replace(/\/$/, '') ?? await serve(path.join(root, 'storybook-static'));
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
const context = await browser.newContext({ deviceScaleFactor: 1, reducedMotion: 'reduce' });
const routes = [
{ label: 'html', transport: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly` },
{ label: 'file-classic', transport: 'html', url: `${pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href}?mode=readOnly` },
{ label: 'react', transport: 'react', url: `${reactOrigin}/p1.html?mode=readOnly` },
{ label: 'storybook', transport: null, url: `${storybookOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` },
];
for (const route of routes) {
const page = await context.newPage(); captureErrors(page, route.label);
try {
await page.setViewportSize(viewports[0]);
await page.goto(route.url); await ready(page);
for (const viewport of viewports) {
await page.setViewportSize(viewport); await ready(page);
const value = await measure(page);
measurements.push({ route: route.label, ...viewport, ...value });
verify(value, `${route.label}/${viewport.width}×${viewport.height}`, viewport);
const filename = `${route.label}-${viewport.width}x${viewport.height}.png`;
await page.screenshot({ path: path.join(output, filename), fullPage: true }); screenshots.push(filename);
}
if (route.transport && route.label !== 'file-classic') {
await page.setViewportSize(viewports[1]); await ready(page);
await tooltip(page, route.transport);
}
} catch (error) { check(false, `${route.label}: browser verification completed`, { message: error.message }); }
finally { await page.close(); }
}
for (const viewport of viewports) {
const values = ['html', 'file-classic', 'react', 'storybook'].map(route => measurements.find(item => item.route === route && item.width === viewport.width));
check(values.every(Boolean) && values.slice(1).every(value => sameGeometry(values[0], value)), `HTML/React/classic/Story shared Core geometry and asset parity/${viewport.width}`, { routes: values.map(value => value?.route ?? 'missing'), toleranceInBasePixels: 0.4 });
}
const interactive = await context.newPage(); captureErrors(interactive, 'interactive');
await interactive.goto(`${htmlOrigin}/p1.html`); await ready(interactive, 'interactive');
const preserved = await interactive.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const node = [...shadow.querySelectorAll('.node')].find(node => Number(node.dataset.organizationDepth ?? node.dataset.depth) >= 2 && node.querySelector('[data-card-kind=leader]'));
const card = node?.querySelector('[data-card-kind=leader]');
return { organizationId: node?.dataset.organizationId, size: card?.dataset.cardAssetSize, width: card?.offsetWidth, height: card?.offsetHeight, toggleCount: shadow.querySelectorAll('.hm-orgchart-toggle').length, overlayCount: shadow.querySelectorAll('.hm-orgchart-workdesk-overlay').length };
});
check(preserved.size === 'leader' && preserved.width === 146 && preserved.height === 68 && preserved.toggleCount > 0 && preserved.overlayCount === 0, 'interactive default preserves existing lower-leader large-card policy and controls', preserved);
await interactive.close();
check(createHash('sha256').update(await readFile(fixturePath)).digest('hex') === fixtureHash, 'P2 browser verifier preserves original employee fixture bytes and IDs', { sha256: fixtureHash });
check(errors.length === 0, 'P2 routes have no browser console/runtime errors', errors);
} catch (error) { check(false, 'P2 verifier setup/completion', { message: error.message }); }
finally {
await writeFile(path.join(output, 'browser.json'), JSON.stringify({
browser: browser?.version(), scope: 'P2 only; no existing v0/P1 suite expectation changes',
transportPolicy: 'HTTP for ESM/React; true file:// classic without file-access browser flag',
tooltipMethod: 'Induced 8px name width on one rendered 홍 길 동, restored after real hover/Tab/Escape. Fixture and displayed text are unchanged.',
fixtureSha256: fixtureHash,
connectorCollisionVerification: 'NOT VERIFIED by automation: path bounding boxes do not represent curved segments. Review saved full-page screenshots for routing and overlap.',
textInkCollisionVerification: 'NOT VERIFIED by automation: font Range ascent/descent envelopes may overlap at the confirmed 0px internal row gap. Actual element line boxes, horizontal role/badge boxes, horizontal overflow and card/avatar/logo boundaries are checked; fontRangeOverlaps retain raw envelopes for screenshot pixel review.',
results, errors, measurements, screenshots,
}, null, 2));
await browser?.close();
for (const server of servers) await new Promise(resolve => server.close(resolve));
}
const failures = results.filter(result => result.status === 'FAIL');
console.log(JSON.stringify({ output, checks: results.length, failureCount: failures.length, failures: failures.slice(0, 20).map(result => result.name), ...(failures.length > 20 ? { additionalFailures: failures.length - 20 } : {}) }, null, 2));
if (failures.length) process.exitCode = 1;
+195
View File
@@ -0,0 +1,195 @@
import http from 'node:http';
import path from 'node:path';
import fs from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
// P3.1 change-specific checks. The prior 732 checks and their evidence are retained.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p3.1');
const fixtureBytes = await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'));
const fixture = JSON.parse(fixtureBytes);
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs').href);
const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }];
const results = [], measurements = [], fontEvidence = [], errors = [], servers = [];
const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const near = (a, b, tolerance = .4) => Number.isFinite(a) && Math.abs(a - b) <= tolerance;
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' };
await fs.mkdir(output, { recursive: true });
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/__p31_link') return response.end('<!doctype html><link rel="icon" href="data:,"><p id="proof">Group link reached</p>');
if (url.pathname === '/__p31_photo.svg') { response.setHeader('Content-Type', 'image/svg+xml'); return response.end('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="100"><rect width="80" height="100" fill="#334455"/></svg>'); }
if (url.pathname === '/__p31_bad_photo.png') { response.setHeader('Content-Type', 'image/png'); return response.end('intentional invalid image bytes'); }
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(directory, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
response.end(await fs.readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server);
return `http://127.0.0.1:${server.address().port}`;
}
async function ready(page, mode = 'readOnly') {
await page.waitForFunction(mode => {
const s = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return s?.querySelector(`.hm-orgchart-composition[data-display-mode="${mode}"] [data-card-asset]`) && [...s.querySelectorAll('link[rel=stylesheet]')].every(l => l.sheet);
}, mode, { timeout: 25000 });
await page.evaluate(async () => {
await document.fonts.load('700 20px "Noto Sans KR"', '홍 길 동 협업 소통 허브 경영진 통합시스템');
await document.fonts.ready;
await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(i => i.decode().catch(() => {})));
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
});
}
async function measure(page) {
return page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot, stage = s.querySelector('.hm-orgchart-stage');
const origin = stage.getBoundingClientRect(), scale = origin.width / 1920;
const rect = e => { const r = e.getBoundingClientRect(); return { x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }; };
const style = (e, props) => Object.fromEntries(props.map(p => [p, getComputedStyle(e)[p]]));
const props = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'];
const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => ({
id: c.dataset.employeeId, size: c.dataset.cardAssetSize, kind: c.dataset.cardKind, box: rect(c),
name: c.querySelector('.hm-orgchart-person-name').textContent, role: c.querySelector('.hm-orgchart-person-role').textContent,
badge: c.querySelector('.hm-orgchart-job-title')?.textContent ?? '', photo: c.dataset.photoState,
image: c.querySelector('.hm-orgchart-avatar img') && { src: c.querySelector('.hm-orgchart-avatar img').src, naturalWidth: c.querySelector('.hm-orgchart-avatar img').naturalWidth, placeholder: c.querySelector('.hm-orgchart-avatar img').dataset.photoPlaceholder },
avatar: { box: rect(c.querySelector('.hm-orgchart-avatar')), ...style(c.querySelector('.hm-orgchart-avatar'), ['borderRadius']) },
emptyAttributes: !!c.querySelector('.hm-orgchart-person-attributes:empty'),
text: [...c.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-job-title')].map(e => ({ text: e.textContent, box: rect(e), clientWidth: e.clientWidth, scrollWidth: e.scrollWidth, ...style(e, props) })),
}));
const headers = [...s.querySelectorAll('.node[data-organization-id]')].map(n => {
const h = n.querySelector('.hm-orgchart-organization-name'), first = n.querySelector('.hm-orgchart-employee');
return { id: n.dataset.organizationId, depth: Number(n.dataset.organizationDepth), box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, [...props, 'backgroundImage', 'textAlign', 'borderRadius', 'boxShadow']) };
});
const hub = s.querySelector('.hm-orgchart-hub'), label = hub.querySelector('.hm-orgchart-hub-label'), icon = hub.querySelector('.hm-orgchart-hub-icon');
const cells = [...s.querySelectorAll('.hm-orgchart-workdesk-position-table th,.hm-orgchart-workdesk-position-table td')].map(e => {
const r = document.createRange(); r.selectNodeContents(e);
const ranges = [...r.getClientRects()].filter(r => r.width > 0).map(r => ({ x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }));
return { text: e.textContent, box: rect(e), ranges, ...style(e, ['whiteSpace', 'textOverflow', 'overflowX', 'paddingLeft', 'paddingRight', ...props]) };
});
const fontViolations = [...s.querySelectorAll('.hm-orgchart-shell *')].filter(e => [...e.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()) && !getComputedStyle(e).fontFamily.includes('Noto Sans KR')).map(e => ({ class: e.className, text: e.textContent, fontFamily: getComputedStyle(e).fontFamily }));
const table = s.querySelector('.hm-orgchart-workdesk-position-table');
return { scale, cards, headers, hub: { box: rect(hub), icon: rect(icon), label: rect(label), axis: rect(s.querySelector('.hm-orgchart-axis')), ...style(hub, ['paddingLeft', 'paddingRight', 'gap']), font: style(label, props), paths: icon.querySelectorAll('path').length }, cells, fontViolations,
table: { box: rect(table), rows: [...table.rows].map(r => [...r.cells].map(c => c.textContent)), total: table.querySelector('[data-total-count]').textContent },
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
outside: cards.filter(c => c.box.x < -.5 || c.box.y < -.5 || c.box.x + c.box.width > 1920.5 || c.box.y + c.box.height > 915),
connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length,
footer: s.querySelector('.hm-orgchart-affiliation-bar').textContent,
};
});
}
async function glyphFonts(page, route, selectors) {
const session = await page.context().newCDPSession(page);
await session.send('DOM.enable'); await session.send('CSS.enable');
for (const [name, selector] of selectors) {
const target = page.locator(selector).first();
await target.evaluate(e => e.setAttribute('data-p31-font-proof', 'yes'));
const { root: dom } = await session.send('DOM.getDocument', { depth: -1, pierce: true });
const find = node => node.attributes?.some((a, i) => i % 2 === 0 && a === 'data-p31-font-proof') ? node : [...(node.children ?? []), ...(node.shadowRoots ?? [])].map(find).find(Boolean);
const node = find(dom);
const value = await session.send('CSS.getPlatformFontsForNode', { nodeId: node.nodeId });
fontEvidence.push({ route, name, ...value });
// This variable font's internal family is "Noto Sans KR Thin" even at bold weights.
check(value.fonts.length > 0 && value.fonts.every(f => f.familyName.startsWith('Noto Sans KR') && f.postScriptName.startsWith('NotoSansKR') && f.isCustomFont && f.glyphCount > 0), `${route}: actual glyph font ${name}`, value.fonts);
await target.evaluate(e => e.removeAttribute('data-p31-font-proof'));
}
await session.detach();
}
function verify(m, route, v) {
const name = `${route}/${v.width}×${v.height}`;
check(!m.fontViolations.length, `${name}: every text family is scoped Noto Sans KR`, m.fontViolations);
check(m.cards.length === 43 && m.cards.every(c => c.name === '홍 길 동' && c.role === fixture.employees.find(e => e.id === c.id).position.replace(/\s+/gu, '')), `${name}: display-only name/position; 43 cards`, m.cards.map(c => [c.id,c.name,c.role]));
check(m.cards.every(c => c.badge !== '경영진' && c.badge === ((fixture.employees.find(e => e.id === c.id).jobTitle?.trim() === '경영진') ? '' : fixture.employees.find(e => e.id === c.id).jobTitle ?? '')), `${name}: executive badge absent; other raw badges retained`);
check(m.cards.every(c => !c.emptyAttributes), `${name}: no empty badge/attribute container`);
check(m.cards.every(c => { const [w,h] = c.size === 'representative' ? [146,74] : c.size === 'leader' ? [146,68] : c.size === 'employee' ? [108,52] : [114,54]; return near(c.box.width,w) && near(c.box.height,h); }), `${name}: approved card sizes and existing P3 status sizes preserved`);
check(m.cards.every(c => { const [w,h] = c.size === 'representative' ? [36,62] : c.size === 'leader' ? [32,56] : c.size === 'employee' ? [24,44] : [28,28]; return c.photo === 'placeholder' && c.image?.placeholder === 'true' && c.image.naturalWidth > 0 && near(c.avatar.box.width,w) && near(c.avatar.box.height,h) && c.avatar.borderRadius !== '50%'; }) && new Set(m.cards.map(c => c.image?.src)).size === 1, `${name}: common decoded square icon in every noncircular slot`);
check(m.headers.filter(h => h.depth === 1).every(h => h.fontSize === '20px' && h.fontWeight === '700' && h.lineHeight === '36px' && near(h.box.height,36)), `${name}: 1Depth 20/700/36 and header36`, m.headers.filter(h => h.depth === 1));
check(m.headers.filter(h => h.depth >= 2).every(h => { const small = h.id.startsWith('talent-growth-') || h.id.startsWith('business-planning-'); return h.fontSize === (small ? '18px':'20px') && h.fontWeight === (small?'600':'700') && h.lineHeight === (small?'16.9px':'19.7px') && near(h.box.width,114) && near(h.box.height,30.4); }), `${name}: 2Depth source typography/surface`, m.headers.filter(h => h.depth >= 2));
check(m.headers.every(h => h.gap === null || near(h.gap,8)), `${name}: all title-to-first-card gaps 8px`, m.headers.map(h => [h.id,h.gap]));
check(m.hub.font.fontSize === '21px' && m.hub.font.fontWeight === '750' && m.hub.font.lineHeight === '24px' && m.hub.paddingLeft === '22px' && m.hub.paddingRight === '24px' && m.hub.gap === '8px' && near(m.hub.box.height,42) && near(m.hub.icon.width,20) && near(m.hub.icon.height,20) && near(m.hub.icon.y+10,m.hub.label.y+m.hub.label.height/2) && near(m.hub.box.x+m.hub.box.width/2,m.hub.axis.x+m.hub.axis.width/2) && m.hub.paths === 3, `${name}: hub font/padding/icon/axis alignment`, m.hub);
check(m.cells.every(c => c.whiteSpace === 'nowrap' && c.textOverflow !== 'ellipsis' && c.ranges.length === 1 && c.ranges[0].x >= c.box.x + parseFloat(c.paddingLeft) - .5 && c.ranges[0].x+c.ranges[0].width <= c.box.x+c.box.width-parseFloat(c.paddingRight)+.5), `${name}: every table label fits on one line without clipping`, m.cells.filter(c => c.ranges.length !== 1 || c.ranges[0].width > c.box.width-parseFloat(c.paddingLeft)-parseFloat(c.paddingRight)+.5));
check(near(m.table.box.width,330,1) && m.table.total === '41' && m.footer.includes('41'), `${name}: table width/count and footer retained`, { table:m.table.box,total:m.table.total });
check(m.document.scrollWidth === m.document.clientWidth && m.document.scrollHeight === m.document.clientHeight && !m.outside.length, `${name}: no page scroll; every card inside stage`, m.document);
check(m.cards.every(c => c.text.every(t => t.scrollWidth <= t.clientWidth+1 && t.box.x >= c.box.x-.5 && t.box.x+t.box.width <= c.box.x+c.box.width+.5)), `${name}: card typography has no new horizontal overflow`);
}
const dialogTargets = [['representative','.hm-orgchart-top-leaders .hm-orgchart-employee'],['depth1','.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'],['depth2','.node[data-organization-depth="2"] .hm-orgchart-employee[data-card-kind="leader"]'],['employee','.hm-orgchart-engine .hm-orgchart-employee[data-card-kind="employee"]'],['dispatched','.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'],['onLeave','.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee']];
async function dialogs(page, route, viewport) {
await page.setViewportSize(viewport); await ready(page);
for (const [kind, selector] of dialogTargets) {
const card = page.locator(selector).first(), dialog = page.locator('dialog[open]');
const id = await card.getAttribute('data-employee-id');
await card.click(); await dialog.waitFor({ state:'visible' });
const value = await dialog.evaluate(d => ({ fields:Object.fromEntries([...d.querySelectorAll('dt')].map(t=>[t.textContent,t.nextElementSibling.textContent])), font:getComputedStyle(d).fontFamily, width:d.getBoundingClientRect().width, scaled:!!d.closest('.hm-orgchart-stage'), transform:getComputedStyle(d).transform }));
const employee = fixture.employees.find(e=>e.id===id);
check(value.fields['이름']==='홍 길 동' && value.fields['직위']===employee.position && (!employee.jobTitle || value.fields['직책']===employee.jobTitle) && value.font.includes('Noto Sans KR') && !value.scaled && value.transform==='none' && near(value.width,Math.min(420,viewport.width-32)), `${route}/${viewport.width}: ${kind} popup retains data/font/unscaled size`, value);
if (kind==='representative' && viewport.width===1920) await glyphFonts(page,route,[['popup name','dialog[open] dd']]);
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
check(await dialog.evaluate(d=>d.contains(d.getRootNode().activeElement)),`${route}/${viewport.width}: ${kind} modal focus trap`);
await page.keyboard.press('Escape'); await dialog.waitFor({state:'hidden'});
check(await card.evaluate(e=>e.getRootNode().activeElement===e),`${route}/${viewport.width}: ${kind} ESC focus return`);
if(kind==='representative') { await card.press('Enter'); await dialog.waitFor({state:'visible'}); await dialog.locator('button').click(); await dialog.waitFor({state:'hidden'}); await card.press('Space'); await dialog.waitFor({state:'visible'}); await page.keyboard.press('Escape'); check(await card.evaluate(e=>e.getRootNode().activeElement===e),`${route}/${viewport.width}: Enter/Space/close preserve activation`); }
}
}
async function setData(page,data,transport) { await page.evaluate(async({data,transport})=>{ if(transport==='react')window.OrgChartP1React.setData(data);else await window.OrgChartP1.setData(data); },{data,transport}); await ready(page); }
let browser;
try {
const htmlOrigin = await serve(path.join(root,'examples/org-chart/dist'));
const reactOrigin = await serve(path.join(root,'examples/org-chart-react/dist'));
browser = await chromium.launch({executablePath:process.env.ORGCHART_CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe',headless:true});
const context = await browser.newContext();
const routes = [{name:'html',url:`${htmlOrigin}/p1.html?mode=readOnly`},{name:'file-classic',url:pathToFileURL(path.join(root,'examples/org-chart/dist/p1-classic.html')).href+'?mode=readOnly'},{name:'react',url:`${reactOrigin}/p1.html?mode=readOnly`},{name:'storybook',url:'http://127.0.0.1:6041/iframe.html?id=orgchart--full-composition&viewMode=story'}];
for(const route of routes) {
const page = await context.newPage(); page.on('pageerror',e=>errors.push({route:route.name,type:'runtime',message:e.message})); page.on('console',e=>{if(e.type()==='error')errors.push({route:route.name,type:'console',message:e.text()});});
try {
await page.goto(route.url); await ready(page);
for(const viewport of viewports) { await page.setViewportSize(viewport); await ready(page); const m = await measure(page); measurements.push({route:route.name,...viewport,...m}); verify(m,route.name,viewport); if([1920,390].includes(viewport.width))await page.screenshot({path:path.join(output,`${route.name}-${viewport.width}x${viewport.height}.png`)}); }
await page.setViewportSize(viewports[0]); await ready(page);
await glyphFonts(page,route.name,[['card name','.hm-orgchart-person-name'],['card position','.hm-orgchart-person-role'],['1Depth','.node[data-organization-depth="1"] .hm-orgchart-organization-name'],['2Depth','.node[data-organization-depth="2"] .hm-orgchart-organization-name'],['hub','.hm-orgchart-hub-label'],['table','.hm-orgchart-workdesk-position-table th'],['footer','.hm-orgchart-affiliation-bar-item dt'],['status','.hm-orgchart-status-group .hm-orgchart-summary-title']]);
await dialogs(page,route.name,viewports[0]); await dialogs(page,route.name,viewports.at(-1));
if(['html','react'].includes(route.name)) {
const data = structuredClone(fixture); data.employees[0].profileImage=`${htmlOrigin}/__p31_photo.svg`; data.employees[1].profileImage=`${htmlOrigin}/__p31_bad_photo.png`; data.employees[2].position='책 임';
await setData(page,data,route.name);
const imageState = await page.evaluate(ids=>ids.map(id=>{const c=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`[data-employee-id="${id}"]`),i=c.querySelector('.hm-orgchart-avatar img');return {id,photo:c.dataset.photoState,src:i.src,width:i.naturalWidth,role:c.querySelector('.hm-orgchart-person-role').textContent};}),data.employees.slice(0,3).map(e=>e.id));
check(imageState[0].photo==='image' && imageState[0].width===80 && imageState[1].photo==='placeholder' && imageState[1].width===64 && imageState[2].role==='책임' && data.employees[2].position==='책 임',`${route.name}: real image precedence/load-failure fallback/display whitespace`,imageState);
await setData(page,fixture,route.name);
// The preserved fixture has position=경영진 but no jobTitle=경영진.
// Exercise the requested badge policy with in-memory variants, never edit JSON.
const executiveIds = await page.evaluate(selectors=>selectors.map(selector=>document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(selector).dataset.employeeId),dialogTargets.map(t=>t[1]));
const executiveData = structuredClone(fixture);
for(const employee of executiveData.employees)if(executiveIds.includes(employee.id))employee.jobTitle='경영진';
await setData(page,executiveData,route.name);
const executiveState = await page.evaluate(ids=>[...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(c=>ids.includes(c.dataset.employeeId)).map(c=>({id:c.dataset.employeeId,badge:c.querySelector('.hm-orgchart-job-title')?.textContent,role:c.querySelector('.hm-orgchart-person-role').textContent,empty:!!c.querySelector('.hm-orgchart-person-attributes:empty')})),executiveIds);
check(executiveState.length>=6 && executiveState.every(c=>!c.badge&&!c.empty&&c.role===fixture.employees.find(e=>e.id===c.id).position),`${route.name}: executive badge suppressed in all six card roles; positions preserved`,executiveState);
await page.locator(dialogTargets[0][1]).click();await page.locator('dialog[open]').waitFor({state:'visible'});
const rawRole=await page.locator('dialog[open]').evaluate(d=>[...d.querySelectorAll('dt')].find(t=>t.textContent==='직책')?.nextElementSibling.textContent);
check(rawRole==='경영진' && executiveData.employees.find(e=>e.id===executiveIds[0]).jobTitle==='경영진',`${route.name}: hidden badge remains in data and popup contract`);await page.keyboard.press('Escape');
await setData(page,fixture,route.name);
const id=fixture.organizations.find(o=>o.parentId===fixture.organizations.find(o=>o.parentId===null).id).id;
const links={[id]:`${route.name==='html'?htmlOrigin:reactOrigin}/__p31_link`};
await page.evaluate(async({links,transport})=>{if(transport==='react')window.OrgChartP1React.setOptions({organizationLinks:links});else {const s=window.OrgChartP1;s.chart.destroy();s.chart=s.api.createOrgChart({container:document.getElementById('chart-host'),dataSource:new s.api.JsonOrgChartDataSource(s.data),stylesheetUrls:s.stylesheetUrls,displayMode:'readOnly',organizationLinks:links});await s.chart.mount();}},{links,transport:route.name}); await ready(page);
const link=page.locator('.hm-orgchart-organization-link');check(await link.count()===1,`${route.name}: only configured 1Depth title links`);await link.focus();await Promise.all([page.waitForURL(links[id]),page.keyboard.press('Enter')]);check(await page.locator('#proof').textContent()==='Group link reached',`${route.name}: keyboard group link navigation`);
}
} catch(error) { check(false,`${route.name}: focused verification completed`,{message:error.message,stack:error.stack}); }
await page.close();
}
for(const v of viewports) {
const variants=measurements.filter(m=>m.width===v.width);
const base=variants.find(m=>m.route==='html');
const equal=(a,b)=>['x','y','width','height'].every(k=>near(a[k],b[k]));
check(variants.length===4 && variants.every(m=>m.cards.length===base.cards.length && m.cards.every((c,i)=>equal(c.box,base.cards[i].box)&&c.name===base.cards[i].name&&c.role===base.cards[i].role&&c.badge===base.cards[i].badge&&c.image.src===base.cards[i].image.src)&&equal(m.hub.box,base.hub.box)&&JSON.stringify(m.table.rows)===JSON.stringify(base.table.rows)&&equal(m.table.box,base.table.box)),`HTML/classic/React/Storybook identical content/geometry/${v.width}`);
}
const standalone=await context.newPage();await standalone.goto(`${htmlOrigin}/p1.html`);await ready(standalone,'interactive');
const standaloneData=structuredClone(fixture);standaloneData.employees[0].jobTitle='경영진';await standalone.evaluate(async data=>window.OrgChartP1.setData(data),standaloneData);await ready(standalone,'interactive');
check((await standalone.locator('.hm-orgchart-job-title').allTextContents()).includes('경영진'),'shared renderer preserves executive badge outside readOnly Full Composition');await standalone.close();
const index=await fetch('http://127.0.0.1:6041/index.json').then(r=>r.json());const userStories=Object.values(index.entries).filter(e=>e.title==='OrgChart'&&e.type==='story'&&!e.tags?.includes('internal'));check(userStories.length===3,'existing Storybook three user menus retained',userStories.map(e=>e.name));
check(!errors.length,'no new browser console/runtime errors',errors);
} finally {
await browser?.close(); for(const server of servers)await new Promise(r=>server.close(r));
const summary={total:results.length,pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length};
await fs.writeFile(path.join(output,'browser.json'),JSON.stringify({scope:'P3.1 only; P3 732 checks retained without rerun',fixtureSha256:createHash('sha256').update(fixtureBytes).digest('hex'),summary,results,measurements,fontEvidence,errors},null,2));
console.log(JSON.stringify({summary,errors,failures:results.filter(r=>r.status==='FAIL').map(r=>r.name)},null,2));if(summary.fail)process.exitCode=1;
}
+254
View File
@@ -0,0 +1,254 @@
import http from 'node:http';
import path from 'node:path';
import fs from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
// P3.2 change-specific evidence. Existing P3.1 486-check evidence is preserved.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p3.2');
const baselineMode = process.argv.includes('--baseline');
const policiesOnly = process.argv.includes('--policies-only');
const fixtureBytes = await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'));
const fixture = JSON.parse(fixtureBytes);
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs').href);
const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }];
const storyOrigin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6041';
const results = [], measurements = [], errors = [], servers = [];
const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const near = (a, b, tolerance = .4) => Number.isFinite(a) && Math.abs(a - b) <= tolerance;
const sameBox = (a, b) => a && b && ['x', 'y', 'width', 'height'].every(k => near(a[k], b[k]));
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' };
await fs.mkdir(output, { recursive: true });
const previous = baselineMode || policiesOnly ? null : JSON.parse(await fs.readFile(path.join(output, 'baseline-browser.json'), 'utf8'));
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/__p32_link') return response.end('<!doctype html><link rel="icon" href="data:,"><p id="proof">Group link reached</p>');
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(directory, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
response.end(await fs.readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server);
return `http://127.0.0.1:${server.address().port}`;
}
async function ready(page) {
await page.waitForFunction(() => {
const s = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return s?.querySelector('.hm-orgchart-composition[data-display-mode="readOnly"] [data-card-asset]') && [...s.querySelectorAll('link[rel=stylesheet]')].every(l => l.sheet);
}, null, { timeout: 25000 });
await page.evaluate(async () => {
await document.fonts.load('600 18px "Noto Sans KR"', '홍 길 동');
await document.fonts.ready;
await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(i => i.decode().catch(() => {})));
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
});
}
async function measure(page) {
return page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot, stage = s.querySelector('.hm-orgchart-stage');
const origin = stage.getBoundingClientRect(), scale = origin.width / 1920;
const rect = e => { const r = e.getBoundingClientRect(); return { x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }; };
const relative = (r, c) => ({ x: r.x - c.x, y: r.y - c.y, width: r.width, height: r.height });
const style = (e, props) => Object.fromEntries(props.map(p => [p, getComputedStyle(e)[p]]));
const typography = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'letterSpacing'];
const contained = (a, b, tolerance = .5) => a.x >= b.x - tolerance && a.y >= b.y - tolerance && a.x + a.width <= b.x + b.width + tolerance && a.y + a.height <= b.y + b.height + tolerance;
const overlap = (a, b) => a && b && Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > .5 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > .5;
const inks = new Map();
// Reuse the approved P3 logo test method: natural PNG alpha bounds (>16),
// transformed through object-fit:contain. Transparent image padding is excluded.
const logoPixels = img => {
if (!img?.naturalWidth) return null;
if (!inks.has(img.src)) {
const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let left = canvas.width, right = -1, top = canvas.height, bottom = -1;
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) if (pixels[(y * canvas.width + x) * 4 + 3] > 16) {
left = Math.min(left, x); right = Math.max(right, x + 1); top = Math.min(top, y); bottom = Math.max(bottom, y + 1);
}
inks.set(img.src, { left, right, top, bottom });
}
const a = inks.get(img.src), b = rect(img), fit = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight);
const x = b.x + (b.width - img.naturalWidth * fit) / 2, y = b.y + (b.height - img.naturalHeight * fit) / 2;
return { x: x + a.left * fit, y: y + a.top * fit, width: (a.right - a.left) * fit, height: (a.bottom - a.top) * fit };
};
const textMeasure = e => {
const box = rect(e), r = document.createRange(); r.selectNodeContents(e);
const ranges = [...r.getClientRects()].filter(r => r.width > 0).map(r => ({ x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }));
return { text: e.textContent, box, ranges, clientWidth: e.clientWidth, scrollWidth: e.scrollWidth, ...style(e, typography) };
};
const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => {
const box = rect(c), status = c.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? null;
const avatar = rect(c.querySelector('.hm-orgchart-avatar')), mark = c.querySelector('.hm-orgchart-affiliation-mark'), img = mark?.querySelector('img');
const imageBox = img && rect(img), fit = img?.naturalWidth ? Math.min(imageBox.width / img.naturalWidth, imageBox.height / img.naturalHeight) : 0;
const contentBox = img ? { x: imageBox.x + (imageBox.width - img.naturalWidth * fit) / 2, y: imageBox.y + (imageBox.height - img.naturalHeight * fit) / 2, width: img.naturalWidth * fit, height: img.naturalHeight * fit } : null;
const logo = img ? { box: imageBox, contentBox, local: relative(imageBox, box), ink: logoPixels(img), naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, src: img.src, ...style(img, ['objectFit', 'filter', 'opacity', 'backgroundColor']) } : null;
const text = [...c.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-job-title')].map(textMeasure);
const collisions = [], outside = [];
if (!contained(avatar, box)) outside.push('avatar');
if (logo && !contained(logo.box, box)) outside.push('logo image box');
if (logo?.ink && overlap(logo.ink, avatar)) collisions.push('logo/avatar');
for (const t of text) {
if (t.scrollWidth > t.clientWidth + 1) outside.push(`${t.text}: horizontal overflow`);
for (const r of t.ranges) {
// Text Range includes font ascent/descent, and is conservative for logo
// and avatar tests. Adjacent Range-to-Range intersections are recorded
// separately; they are not treated as painted glyph collisions.
const visible = { x: Math.max(r.x, t.box.x), y: r.y, width: Math.max(0, Math.min(r.x + r.width, t.box.x + t.box.width) - Math.max(r.x, t.box.x)), height: r.height };
if (!contained(r, box)) outside.push(`${t.text}: text Range outside card`);
if (overlap(visible, avatar)) collisions.push(`${t.text}/avatar`);
if (logo?.ink && overlap(visible, logo.ink)) collisions.push(`${t.text}/logo ink`);
}
}
const fontRangeOverlaps = [];
for (const [i, a] of text.entries()) for (const b of text.slice(i + 1)) for (const ar of a.ranges) for (const br of b.ranges) if (overlap(ar, br)) fontRangeOverlaps.push([a.text, b.text]);
const name = textMeasure(c.querySelector('.hm-orgchart-person-name')), role = textMeasure(c.querySelector('.hm-orgchart-person-role'));
const photo = c.querySelector('.hm-orgchart-avatar img');
return { key: `${status ?? 'tree'}:${c.dataset.employeeId}`, id: c.dataset.employeeId, status, size: c.dataset.cardAssetSize ?? null, kind: c.dataset.cardKind, box,
name, role, badge: c.querySelector('.hm-orgchart-job-title')?.textContent ?? '', statusLabel: c.querySelector('.hm-orgchart-status-label')?.textContent ?? '',
avatar: { box: avatar, local: relative(avatar, box), photoState: c.dataset.photoState, placeholder: photo.dataset.photoPlaceholder, naturalWidth: photo.naturalWidth },
nameLocal: relative(name.box, box), roleLocal: relative(role.box, box), text, logo, mark: mark ? { box: rect(mark), ...style(mark, ['backgroundColor', 'border', 'boxShadow']) } : null,
surface: style(c, ['backgroundImage', 'border', 'borderRadius', 'padding', 'columnGap']), collisions, outside, fontRangeOverlaps };
});
const headers = [...s.querySelectorAll('.node[data-organization-id]')].map(n => {
const h = n.querySelector('.hm-orgchart-organization-name'), first = n.querySelector('.hm-orgchart-employee');
return { id: n.dataset.organizationId, depth: Number(n.dataset.organizationDepth), box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, typography) };
});
const statusHeaders = [...s.querySelectorAll('.hm-orgchart-status-group')].map(g => {
const h = g.querySelector('.hm-orgchart-summary-title'), first = g.querySelector('.hm-orgchart-employee');
return { status: g.dataset.workStatus, text: h.textContent, box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, [...typography, 'backgroundImage']) };
});
const footer = s.querySelector('.hm-orgchart-affiliation-bar'), table = s.querySelector('.hm-orgchart-workdesk-position-table');
return { scale, cards, headers, statusHeaders,
footer: { box: rect(footer), text: footer.textContent, logos: [...footer.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(img => ({ id: img.parentElement.dataset.affiliationId, box: rect(img), ...style(img, ['width', 'height']) })) },
table: { box: rect(table), text: table.textContent, total: table.querySelector('[data-total-count]').textContent },
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
policies: { displayMode: s.querySelector('.hm-orgchart-composition').dataset.displayMode, collapseButtons: s.querySelectorAll('.hm-orgchart-toggle').length, connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length, arrows: s.querySelectorAll('.hm-orgchart-workdesk-overlay [data-arrow]').length, background: getComputedStyle(s.querySelector('.hm-orgchart-composition')).backgroundColor },
stage: rect(stage) };
});
}
async function measurePolicies(page) {
return page.evaluate(() => {
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
const composition = s.querySelector('.hm-orgchart-composition');
return { displayMode: composition.dataset.displayMode, collapseButtons: s.querySelectorAll('.hm-orgchart-toggle').length,
connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length,
arrows: s.querySelectorAll('.hm-orgchart-workdesk-overlay [data-arrow]').length,
background: getComputedStyle(composition).backgroundColor };
});
}
function verify(m, before, route, viewport) {
const label = `${route}/${viewport.width}×${viewport.height}`;
check(m.cards.length === before.cards.length && m.cards.every(c => before.cards.some(b => b.key === c.key)), `${label}: same employee IDs and rendered count`);
check(m.cards.every(c => c.name.text === '홍 길 동' && !/\s/u.test(c.role.text) && !/\s/u.test(c.badge) && c.name.fontFamily.includes('Noto Sans KR') && c.role.fontFamily.includes('Noto Sans KR')), `${label}: Noto Sans KR, spaced names and compact position/badge text`);
check(m.cards.every(c => {
const b = before.cards.find(b => b.key === c.key);
const preserved = ['fontWeight', 'lineHeight', 'color', 'letterSpacing'];
if (c.status) return c.name.fontSize === '18px' && c.role.fontSize === '15px';
if (c.size === 'representative') return [...preserved, 'fontSize'].every(p => c.name[p] === b.name[p] && c.role[p] === b.role[p]);
return c.name.fontSize === (c.size === 'leader' ? '24px' : '18px') && c.role.fontSize === (c.size === 'leader' ? '18px' : '15px') && preserved.every(p => c.name[p] === b.name[p] && c.role[p] === b.role[p]);
}), `${label}: requested card typography; representative and other approved properties preserved`);
const general = m.headers.find(h => h.id === 'integrated-system-1');
check(general && m.statusHeaders.every(h => ['fontSize', 'fontWeight', 'lineHeight'].every(p => h[p] === general[p]) && h.backgroundImage === before.statusHeaders.find(b => b.status === h.status).backgroundImage), `${label}: status title equals standard 2Depth typography; gray gradient retained`, m.statusHeaders);
const normal = m.cards.find(c => !c.status && c.size === 'employee' && c.kind === 'employee' && !c.badge);
const common = ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'];
check(normal && m.cards.filter(c => c.status).every(c => sameBox(c.avatar.local, normal.avatar.local) && sameBox(c.nameLocal, normal.nameLocal) && sameBox(c.roleLocal, normal.roleLocal) && common.every(p => c.name[p] === normal.name[p] && c.role[p] === normal.role[p]) && c.statusLabel === '' && c.name.color === before.cards.find(b => b.key === c.key).name.color && c.role.color === before.cards.find(b => b.key === c.key).role.color), `${label}: status photo/text geometry and typography equals compact cards; own colors retained; no status line`, m.cards.filter(c => c.status).map(c => ({ key: c.key, avatar: c.avatar.local, name: c.nameLocal, role: c.roleLocal })));
check(m.cards.filter(c => c.status).every(c => { const b = before.cards.find(b => b.key === c.key); return ['backgroundImage', 'border', 'borderRadius'].every(p => c.surface[p] === b.surface[p]) && c.logo.filter === b.logo.filter && c.logo.opacity === b.logo.opacity; }), `${label}: status card surface and image-only grayscale/opacity retained`);
check(m.cards.every(c => { const [w, h] = c.size === 'representative' ? [146, 74] : c.size === 'leader' ? [146, 68] : [108, 52]; return near(c.box.width, w) && near(c.box.height, h); }), `${label}: card sizes remain 146×74/146×68/108×52; status corrected to requested108×52`);
check([...m.headers, ...m.statusHeaders].every(h => h.gap === null || near(h.gap, 8)), `${label}: every title↔first card gap8px`);
check(m.cards.every(c => c.logo && c.logo.naturalWidth && c.logo.objectFit === 'contain' && c.logo.src === before.cards.find(b => b.key === c.key).logo?.src && ['width', 'height'].every(p => { const old = before.cards.find(b => b.key === c.key).logo; const fitted = old.contentBox ?? { width: old.naturalWidth * Math.min(old.box.width / old.naturalWidth, old.box.height / old.naturalHeight), height: old.naturalHeight * Math.min(old.box.width / old.naturalWidth, old.box.height / old.naturalHeight) }; return near(c.logo.contentBox[p] / fitted[p], 1.5, .005); }) && ['width', 'height'].every(p => near(c.logo.ink[p] / before.cards.find(b => b.key === c.key).logo.ink[p], 1.5, .005))), `${label}: all actual card logo contained images and painted ink scale1.5×; assets/ratio retained`, m.cards.map(c => ({ key: c.key, image: c.logo?.box, fitted: c.logo?.contentBox, ink: c.logo?.ink })));
check(m.cards.every(c => c.mark.backgroundColor === 'rgba(0, 0, 0, 0)' && c.mark.border.startsWith('0px none') && c.mark.boxShadow === 'none'), `${label}: card logos have no added frame/background/shadow`);
check(m.cards.every(c => !c.collisions.length && !c.outside.length), `${label}: no logo/avatar/visible text overlap or clipping`, m.cards.filter(c => c.collisions.length || c.outside.length).map(c => ({ key: c.key, collisions: c.collisions, outside: c.outside })));
check(m.cards.every(c => c.avatar.photoState === 'placeholder' && c.avatar.placeholder === 'true' && c.avatar.naturalWidth > 0 && c.badge !== '경영진'), `${label}: common photo placeholder and no executive badge`);
check(m.footer.text === before.footer.text && sameBox(m.footer.box, before.footer.box) && m.footer.logos.length === before.footer.logos.length && m.footer.logos.every((l, i) => sameBox(l.box, before.footer.logos[i].box)), `${label}: footer counts/geometry/logos unchanged`);
check(m.table.text === before.table.text && m.table.total === '41' && sameBox(m.table.box, before.table.box) && m.policies.displayMode === 'readOnly' && m.policies.collapseButtons === 0 && m.policies.connectorPaths === before.policies.connectorPaths && m.policies.arrows === 5 && m.policies.background === 'rgb(245, 248, 251)', `${label}: readOnly/background/connectors/statistics policies retained`, m.policies);
check(m.document.scrollWidth === m.document.clientWidth && m.document.scrollHeight === m.document.clientHeight && m.cards.every(c => c.box.x >= -.5 && c.box.y >= -.5 && c.box.x + c.box.width <= 1920.5 && c.box.y + c.box.height <= 915), `${label}: no page scroll; cards contained in scaled stage`, m.document);
}
const dialogTargets = [['representative', '.hm-orgchart-top-leaders .hm-orgchart-employee'], ['depth1', '.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'], ['small', '.hm-orgchart-engine .hm-orgchart-employee[data-card-asset-size="employee"]'], ['dispatched', '.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'], ['onLeave', '.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee']];
async function dialogs(page, route, viewport) {
await page.setViewportSize(viewport); await ready(page);
for (const [kind, selector] of dialogTargets) {
const card = page.locator(selector).first(), dialog = page.locator('dialog[open]'), id = await card.getAttribute('data-employee-id');
await card.click(); await dialog.waitFor({ state: 'visible' });
const value = await dialog.evaluate(d => ({ fields: Object.fromEntries([...d.querySelectorAll('dt')].map(t => [t.textContent, t.nextElementSibling.textContent])), scaled: !!d.closest('.hm-orgchart-stage'), transform: getComputedStyle(d).transform, width: d.getBoundingClientRect().width }));
const employee = fixture.employees.find(e => e.id === id);
check(value.fields['이름'] === '홍 길 동' && value.fields['직위'] === employee.position && (!employee.jobTitle || value.fields['직책'] === employee.jobTitle) && !value.scaled && value.transform === 'none' && near(value.width, Math.min(420, viewport.width - 32)), `${route}/${viewport.width}: ${kind} popup retains data/unscaled size`, value);
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
check(await dialog.evaluate(d => d.contains(d.getRootNode().activeElement)), `${route}/${viewport.width}: ${kind} popup focus trap`);
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' });
check(await card.evaluate(e => e.getRootNode().activeElement === e), `${route}/${viewport.width}: ${kind} ESC focus return`);
}
}
async function links(page, route, origin) {
const id = fixture.organizations.find(o => o.parentId === fixture.organizations.find(o => o.parentId === null).id).id;
const configured = { [id]: `${origin}/__p32_link` };
await page.evaluate(async ({ configured, transport }) => {
if (transport === 'react') window.OrgChartP1React.setOptions({ organizationLinks: configured });
else { const s = window.OrgChartP1; s.chart.destroy(); s.chart = s.api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new s.api.JsonOrgChartDataSource(s.data), stylesheetUrls: s.stylesheetUrls, displayMode: 'readOnly', organizationLinks: configured }); await s.chart.mount(); }
}, { configured, transport: route });
await ready(page);
const link = page.locator('.hm-orgchart-organization-link'); check(await link.count() === 1, `${route}: only configured 1Depth title becomes link`);
await link.focus(); await Promise.all([page.waitForURL(configured[id]), page.keyboard.press('Enter')]);
check(await page.locator('#proof').textContent() === 'Group link reached', `${route}: real1Depth keyboard link navigation`);
}
let browser;
try {
const htmlOrigin = await serve(path.join(root, 'examples/org-chart/dist'));
const reactOrigin = await serve(path.join(root, 'examples/org-chart-react/dist'));
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
const context = await browser.newContext();
const routes = [{ name: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly`, origin: htmlOrigin }, { name: 'file-classic', url: pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href + '?mode=readOnly' }, { name: 'react', url: `${reactOrigin}/p1.html?mode=readOnly`, origin: reactOrigin }, { name: 'storybook', url: `${storyOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` }];
for (const route of routes) {
const page = await context.newPage();
page.on('pageerror', e => errors.push({ route: route.name, type: 'runtime', message: e.message }));
page.on('console', e => { if (e.type() === 'error') errors.push({ route: route.name, type: 'console', message: e.text() }); });
try {
await page.goto(route.url); await ready(page);
if (policiesOnly) {
await page.setViewportSize(viewports[0]); await ready(page);
const policies = await measurePolicies(page);
measurements.push({ route: route.name, ...viewports[0], policies });
check(policies.displayMode === 'readOnly' && policies.collapseButtons === 0, `${route.name}: readOnly has no actual hm-orgchart-toggle controls`, policies);
check(policies.background === 'rgb(245, 248, 251)', `${route.name}: actual composition background is #F5F8FB`, policies.background);
check(policies.connectorPaths === 5 && policies.arrows === 5, `${route.name}: actual overlay retains five connector paths and five collaboration arrows`, policies);
} else {
for (const viewport of viewports) {
await page.setViewportSize(viewport); await ready(page);
const m = await measure(page); measurements.push({ route: route.name, ...viewport, ...m });
if (!baselineMode) verify(m, previous.measurements.find(b => b.route === route.name && b.width === viewport.width), route.name, viewport);
if ([1920, 390].includes(viewport.width)) await page.screenshot({ path: path.join(output, `${baselineMode ? 'baseline-' : ''}${route.name}-${viewport.width}x${viewport.height}.png`) });
}
if (!baselineMode) {
await dialogs(page, route.name, viewports[0]); await dialogs(page, route.name, viewports.at(-1));
if (['html', 'react'].includes(route.name)) await links(page, route.name, route.origin);
}
}
} catch (error) { check(false, `${route.name}: focused browser capture completed`, { message: error.message, stack: error.stack }); }
await page.close();
}
if (policiesOnly) check(!errors.length, 'no new console/runtime errors during policy-only capture', errors);
else if (!baselineMode) {
for (const viewport of viewports) {
const variants = measurements.filter(m => m.width === viewport.width), base = variants.find(m => m.route === 'html');
check(variants.length === 4 && variants.every(m => m.cards.length === base.cards.length && m.cards.every((c, i) => sameBox(c.box, base.cards[i].box) && sameBox(c.nameLocal, base.cards[i].nameLocal) && sameBox(c.roleLocal, base.cards[i].roleLocal) && c.name.fontSize === base.cards[i].name.fontSize && c.role.fontSize === base.cards[i].role.fontSize && sameBox(c.logo.box, base.cards[i].logo.box))), `HTML/classic/React/Storybook same card typography/geometry/${viewport.width}`);
}
const index = await fetch(`${storyOrigin}/index.json`).then(r => r.json());
const stories = Object.values(index.entries).filter(e => e.title === 'OrgChart' && e.type === 'story' && !e.tags?.includes('internal'));
check(stories.length === 3, 'existing Storybook three user menus retained', stories.map(e => e.name));
check(!errors.length, 'no new console/runtime errors', errors);
}
} finally {
await browser?.close(); for (const server of servers) await new Promise(resolve => server.close(resolve));
const summary = { total: results.length, pass: results.filter(r => r.status === 'PASS').length, fail: results.filter(r => r.status === 'FAIL').length };
const report = { scope: policiesOnly ? 'P3.2 measurement-selector correction only; retain browser.json and do not repeat passed typography/logo/layout/dialog/link checks' : 'P3.2 changes only; prior P3.1 486 checks retained without rerun', baselineMode, policiesOnly, fixtureSha256: createHash('sha256').update(fixtureBytes).digest('hex'), summary, results, measurements, errors,
collisionMethod: 'Natural PNG alpha bounds >16 transformed through object-fit contain, versus visible text Range and avatar rectangles. Adjacent font Range envelopes are recorded only; painted font glyph interpenetration is not certified by this test.' };
await fs.writeFile(path.join(output, policiesOnly ? 'policy-browser.json' : baselineMode ? 'baseline-browser.json' : 'browser.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ baselineMode, policiesOnly, captures: measurements.length, summary, errors, failures: results.filter(r => r.status === 'FAIL').map(r => r.name) }, null, 2));
if (summary.fail || (baselineMode && measurements.length !== routesLength())) process.exitCode = 1;
}
function routesLength() { return 4 * viewports.length; }
+436
View File
@@ -0,0 +1,436 @@
import http from 'node:http';
import path from 'node:path';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
// P3 impact checks only. P2's 484 checks and every earlier report are retained.
// Runtime layout, accessibility interaction, and data aggregation are measured;
// font ink and curved connector routing still require saved-image review.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-p3');
const fixturePath = path.join(root, 'examples/org-chart/data-p1.json');
const fixtureBytes = await readFile(fixturePath);
const fixtureHash = createHash('sha256').update(fixtureBytes).digest('hex');
const fixture = JSON.parse(fixtureBytes.toString('utf8'));
const playwrightPath = process.env.ORGCHART_PLAYWRIGHT_MODULE ?? 'C:\\Users\\user\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\node\\node_modules\\playwright\\index.mjs';
const { chromium } = await import(pathToFileURL(playwrightPath).href);
const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }];
const results = [], measurements = [], interactions = [], errors = [], screenshots = [], servers = [];
const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const near = (actual, expected, tolerance = 0.25) => Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance;
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' };
await mkdir(output, { recursive: true });
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/__p3_link_proof') {
response.setHeader('Content-Type', 'text/html');
return response.end('<!doctype html><title>P3 verifier navigation proof</title><link rel="icon" href="data:,"><p id="proof">Configured organization link reached</p>');
}
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(directory, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
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));
servers.push(server);
return `http://127.0.0.1:${server.address().port}`;
}
function captureErrors(page, route) {
page.on('pageerror', error => errors.push({ route, type: 'pageerror', message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ route, type: 'console', message: message.text() }); });
}
async function ready(page, mode = 'readOnly') {
await page.waitForFunction(expectedMode => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return shadow?.querySelector(`.hm-orgchart-composition[data-display-mode="${expectedMode}"] [data-card-asset]`)
&& !shadow.querySelector('.hm-orgchart-state')?.textContent
&& [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet);
}, mode, { timeout: 20000 });
await page.evaluate(async () => {
await document.fonts.ready;
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
await Promise.all([...shadow.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
}
async function measure(page) {
return page.evaluate(() => {
const host = document.querySelector('.hm-orgchart-host'), shadow = host.shadowRoot;
// Chrome can serialize a zero letter-spacing as "normal". Compare against
// an independently authored zero-spacing probe, not a guessed string.
const zeroSpacingProbe = document.createElement('span');
zeroSpacingProbe.textContent = '홍 길 동';
zeroSpacingProbe.style.letterSpacing = '0';
shadow.append(zeroSpacingProbe);
const zeroSpacingSerialization = { authored: zeroSpacingProbe.style.letterSpacing, computed: getComputedStyle(zeroSpacingProbe).letterSpacing };
zeroSpacingProbe.remove();
const composition = shadow.querySelector('.hm-orgchart-composition'), stage = shadow.querySelector('.hm-orgchart-stage');
const origin = stage.getBoundingClientRect(), scale = origin.width / stage.offsetWidth;
const rect = element => {
if (!element) return null;
const box = element.getBoundingClientRect();
return { x: (box.left - origin.left) / scale, y: (box.top - origin.top) / scale, width: box.width / scale, height: box.height / scale };
};
const screenRect = element => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height, right: box.right, bottom: box.bottom };
};
const dimensions = element => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY });
const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
const overlaps = (a, b) => Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.75 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.75;
const contained = (inner, outer, tolerance = 0.75) => inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.x + inner.width <= outer.x + outer.width + tolerance && inner.y + inner.height <= outer.y + outer.height + tolerance;
const cssValues = (element, properties) => element ? Object.fromEntries(properties.map(name => [name, getComputedStyle(element)[name]])) : null;
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => {
const node = card.closest('.node'), box = rect(card), name = card.querySelector('.hm-orgchart-person-name');
const role = card.querySelector('.hm-orgchart-person-role'), badge = card.querySelector('.hm-orgchart-job-title');
const row = card.querySelector('.hm-orgchart-position-row'), avatar = card.querySelector('.hm-orgchart-avatar');
const textBoxes = [...card.querySelectorAll('.hm-orgchart-person-role, .hm-orgchart-person-name, .hm-orgchart-job-title')].map(element => {
const range = document.createRange(); range.selectNodeContents(element);
const envelopes = [...range.getClientRects()].map(rangeBox => ({ x: (rangeBox.x - origin.left) / scale, y: (rangeBox.y - origin.top) / scale, width: rangeBox.width / scale, height: rangeBox.height / scale }));
return { className: element.className, box: rect(element), clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, envelopes };
});
const issues = textBoxes.flatMap(item => [
...(item.scrollWidth > item.clientWidth + 1 ? [`${item.className}: horizontal text overflow`] : []),
...(!contained(item.box, box) ? [`${item.className}: element outside card`] : []),
...(item.envelopes.some(envelope => envelope.x < box.x - 0.75 || envelope.x + envelope.width > box.x + box.width + 0.75) ? [`${item.className}: text outside horizontal card bounds`] : []),
]);
const logo = card.querySelector('.hm-orgchart-affiliation-mark');
const avatarBox = rect(avatar), logoBox = rect(logo);
for (const item of textBoxes) {
if (overlaps(item.box, avatarBox)) issues.push(`${item.className}: avatar element overlap`);
if (logoBox && item.envelopes.some(envelope => overlaps(envelope, logoBox))) issues.push(`${item.className}: text envelope/logo overlap`);
}
return {
id: card.dataset.employeeId, kind: card.dataset.cardKind, asset: card.dataset.cardAsset ?? null, size: card.dataset.cardAssetSize ?? null,
organizationId: node?.dataset.organizationId ?? null, depth: node ? Number(node.dataset.organizationDepth ?? node.dataset.depth) : null,
statusGroup: card.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? null, box,
name: text(name), role: text(role), badge: text(badge), nameAccessible: name.getAttribute('aria-label'),
photoState: card.dataset.photoState, avatarText: text(avatar), avatarBox, avatarStyle: cssValues(avatar, ['borderRadius', 'backgroundColor']),
photoStyle: cssValues(avatar.querySelector('img'), ['objectFit']),
rowGap: row ? rect(name).y - rect(row).y - rect(row).height : null,
badgeGap: row && badge ? rect(badge).x - rect(role).x - rect(role).width : null,
badgeInRow: !badge || !!badge.closest('.hm-orgchart-position-row'),
badgeStyle: cssValues(badge, ['color', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderRadius', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'paddingLeft', 'paddingRight']),
nameStyle: cssValues(name, ['fontSize', 'fontWeight', 'lineHeight']),
roleStyle: cssValues(role, ['fontSize', 'fontWeight', 'lineHeight']),
statusLabels: card.querySelectorAll('.hm-orgchart-status-label').length,
keyboardRole: card.getAttribute('role'), tabIndex: card.tabIndex, issues, textBoxes,
};
});
const headers = [...shadow.querySelectorAll('.node[data-organization-id] .hm-orgchart-organization-name')].map(header => {
const node = header.closest('.node'), box = rect(header), first = node.querySelector('.hm-orgchart-employee');
return { id: node.dataset.organizationId, depth: Number(node.dataset.organizationDepth ?? node.dataset.depth), text: text(header), box,
gap: first ? rect(first).y - box.y - box.height : null,
style: cssValues(header, ['backgroundImage', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'textAlign', 'padding', 'borderRadius', 'boxShadow']), link: header.querySelector('a')?.getAttribute('href') ?? null };
});
const pseudoRoutes = [...shadow.querySelectorAll('.hm-orgchart-engine .hierarchy, .hm-orgchart-engine .node')].flatMap(element => ['::before', '::after'].map(pseudo => ({ className: element.className, depth: element.dataset.depth ?? null, pseudo, content: getComputedStyle(element, pseudo).content, display: getComputedStyle(element, pseudo).display })));
const overlay = shadow.querySelector('.hm-orgchart-workdesk-overlay');
const connectors = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay path[data-connector]')].map(element => ({ id: element.dataset.connector, path: element.getAttribute('d') }));
const arrows = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay image[data-arrow]')].map(element => ({ id: element.dataset.arrow, href: (element.getAttribute('href') ?? '').slice(0, 40), box: rect(element) }));
const table = shadow.querySelector('.hm-orgchart-workdesk-position-table');
const tableData = table ? {
title: text(shadow.querySelector('.hm-orgchart-organization-totals-title')),
header: [...table.querySelectorAll('thead th')].map(text),
rows: [...table.querySelectorAll('tbody tr')].map(row => ({ id: row.dataset.organizationId, scope: row.dataset.countScope, values: [...row.children].map(text) })),
total: [...table.querySelectorAll('tfoot tr > *')].map(text), box: rect(table),
} : null;
const hub = shadow.querySelector('.hm-orgchart-hub'), hubIcon = hub?.querySelector('.hm-orgchart-hub-icon'), hubLabel = hub?.querySelector('.hm-orgchart-hub-label');
const bar = shadow.querySelector('.hm-orgchart-affiliation-bar');
const barItems = [...shadow.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ id: item.dataset.affiliationId ?? 'total', count: Number(item.dataset.employeeCount ?? item.dataset.totalCount), box: rect(item), logoLoaded: [...item.querySelectorAll('img')].every(image => image.complete && image.naturalWidth > 0) }));
const modules = [...shadow.querySelectorAll('.hm-orgchart-organization-totals, .hm-orgchart-affiliation-bar, .hm-orgchart-hub')].map(element => ({ id: element.className, box: rect(element) }));
const physical = [...cards.map(card => ({ id: `card:${card.id}:${card.statusGroup ?? 'tree'}`, box: card.box })), ...headers.map(header => ({ id: `header:${header.id}`, box: header.box }))];
const collisions = [];
for (const [index, a] of physical.entries()) for (const b of physical.slice(index + 1)) if (overlaps(a.box, b.box)) collisions.push({ a: a.id, b: b.id });
const moduleCollisions = modules.flatMap(module => physical.filter(item => overlaps(module.box, item.box)).map(item => ({ module: module.id, item: item.id })));
for (const [index, a] of modules.entries()) for (const b of modules.slice(index + 1)) if (overlaps(a.box, b.box)) moduleCollisions.push({ a: a.id, b: b.id });
const outsideStage = [...physical, ...modules].filter(item => !contained(item.box, { x: 0, y: 0, width: stage.offsetWidth, height: stage.offsetHeight })).map(item => item.id);
return {
scale, recordedScale: composition.dataset.scale, baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, zeroSpacingSerialization,
screen: { stage: screenRect(stage), composition: screenRect(composition), host: screenRect(host) },
background: { body: getComputedStyle(document.body).backgroundColor, composition: getComputedStyle(composition).backgroundColor, stage: getComputedStyle(stage).backgroundColor },
document: dimensions(document.documentElement), composition: dimensions(composition), stage: dimensions(stage), host: dimensions(host),
cards, headers, pseudoRoutes, connectors, arrows, overlayViewBox: overlay?.getAttribute('viewBox'),
decorations: [...shadow.querySelectorAll('img[data-workdesk-asset]')].map(image => ({ id: image.dataset.workdeskAsset, loaded: image.complete && image.naturalWidth > 0 })),
table: tableData, bar: bar ? { box: rect(bar), titleCount: bar.querySelectorAll('h1,h2,h3').length, items: barItems } : null,
hub: { box: rect(hub), iconBox: rect(hubIcon), labelBox: rect(hubLabel), paths: hubIcon?.querySelectorAll('path').length ?? 0,
gap: hubLabel && hubIcon ? rect(hubLabel).x - rect(hubIcon).x - rect(hubIcon).width : null,
style: cssValues(hubLabel, ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']), text: text(hubLabel) },
status: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(group => ({ status: group.dataset.workStatus, heading: text(group.querySelector('h3')) })),
collisions, moduleCollisions, outsideStage, debugCount: shadow.querySelectorAll('.hm-orgchart-toggle, .hm-orgchart-kind, .hm-orgchart-domain-id, .hm-orgchart-node-count, .hm-orgchart-vacancy, .hm-orgchart-description, .hm-orgchart-policy').length,
};
});
}
const preferredPositions = ['경영진', '수석', '책임', '선임', '연구'];
const positions = [...preferredPositions.filter(position => fixture.employees.some(employee => employee.position === position)), ...new Set(fixture.employees.map(employee => employee.position).filter(position => !preferredPositions.includes(position)))];
const organizationById = new Map(fixture.organizations.map(organization => [organization.id, organization]));
const depthOf = id => { let depth = 0; while (organizationById.get(id)?.parentId) { id = organizationById.get(id).parentId; depth++; } return depth; };
const majorRows = fixture.organizations.filter(organization => depthOf(organization.id) <= 1);
const descendantsOf = id => { const ids = [id]; for (let index = 0; index < ids.length; index++) ids.push(...fixture.organizations.filter(organization => organization.parentId === ids[index]).map(organization => organization.id)); return ids; };
const expectedRows = majorRows.map(organization => {
const scope = organization.parentId === null && fixture.organizations.some(item => item.parentId === organization.id) ? 'direct' : 'inclusive';
const ids = scope === 'direct' ? [organization.id] : descendantsOf(organization.id);
const employees = fixture.employees.filter(employee => ids.includes(employee.organizationId));
return { id: organization.id, scope, values: [organization.name, ...positions.map(position => String(employees.filter(employee => employee.position === position).length)), String(employees.length)] };
}).filter(row => row.scope !== 'direct' || Number(row.values.at(-1)) > 0);
const expectedTotal = ['계', ...positions.map(position => String(fixture.employees.filter(employee => employee.position === position).length)), String(fixture.employees.length)];
function verify(value, label, viewport) {
const assetCards = value.cards.filter(card => card.asset);
const first = assetCards.filter(card => card.depth === 1 && card.kind === 'leader');
const smaller = assetCards.filter(card => card.depth >= 2 || card.kind === 'employee');
check(value.background.body === 'rgb(245, 248, 251)' && value.background.composition === 'rgb(245, 248, 251)' && value.background.stage === 'rgb(245, 248, 251)', `${label}: Full Composition background #F5F8FB`, value.background);
check(value.pseudoRoutes.every(pseudo => ['none', 'normal'].includes(pseudo.content) || pseudo.display === 'none'), `${label}: every engine hierarchy/node pseudo connector is hidden`, value.pseudoRoutes.filter(pseudo => !['none', 'normal'].includes(pseudo.content) && pseudo.display !== 'none'));
check(value.connectors.length === 5 && value.connectors.every(connector => connector.path) && value.arrows.length === 5 && value.overlayViewBox === '0 0 1920 914.5', `${label}: existing five Workdesk curve paths and five collaboration arrow assets retained`, { connectors: value.connectors, arrows: value.arrows.map(arrow => arrow.id) });
check(value.decorations.length > 0 && value.decorations.every(image => image.loaded), `${label}: original Workdesk decoration assets remain loaded`, value.decorations);
check(value.cards.every(card => card.name === '홍 길 동' && card.nameAccessible === '홍길동' && card.role === fixture.employees.find(employee => employee.id === card.id)?.position && card.badge === (fixture.employees.find(employee => employee.id === card.id)?.jobTitle ?? '')), `${label}: only names are spaced; positions and badges preserve raw strings`, value.cards.map(({ id, name, role, badge }) => ({ id, name, role, badge })));
check(assetCards.every(card => near(card.rowGap, 0) && card.badgeInRow && (card.badgeGap === null || near(card.badgeGap, 4))), `${label}: role and badge share one row, gap4; position/name gap0`, assetCards.map(({ id, rowGap, badgeGap, badgeInRow }) => ({ id, rowGap, badgeGap, badgeInRow })));
check(value.zeroSpacingSerialization.authored === '0px' && assetCards.filter(card => card.badge).every(card => {
const small = card.size === 'employee', style = card.badgeStyle;
return style.borderTopWidth === '1px' && style.borderRadius === '4px' && style.letterSpacing === value.zeroSpacingSerialization.computed && style.fontSize === (small ? '8.5px' : '9px') && style.fontWeight === (small ? '600' : '700') && style.lineHeight === (small ? '8.5px' : '9px') && style.paddingLeft === (small ? '2px' : '3px') && style.paddingRight === (small ? '2px' : '3px');
}), `${label}: badge source border/radius/font restored`, { zeroSpacingSerialization: value.zeroSpacingSerialization, badges: assetCards.filter(card => card.badge).map(({ id, badgeStyle }) => ({ id, badgeStyle })) });
const tagColors = { 'business-planning': 'rgb(37, 78, 126)', 'talent-growth': 'rgb(14, 103, 200)', 'integrated-system': 'rgb(110, 71, 160)', 'design-planning': 'rgb(6, 154, 149)', 'solution-sales': 'rgb(81, 139, 53)' };
check(assetCards.filter(card => card.badge).every(card => card.badgeStyle.color === (tagColors[card.asset] ?? 'rgb(14, 84, 146)')), `${label}: organization-specific badge source colors`, assetCards.filter(card => card.badge).map(({ id, asset, badgeStyle }) => ({ id, asset, color: badgeStyle.color })));
check(assetCards.filter(card => card.badge).every(card => {
const color = tagColors[card.asset] ?? 'rgb(14, 84, 146)', alpha = card.asset in tagColors ? ['0.1', '0.26'] : ['0.08', '0.2'];
const rgba = value => color.replace('rgb(', 'rgba(').replace(')', `, ${value})`);
return card.badgeStyle.backgroundColor === rgba(alpha[0]) && card.badgeStyle.borderTopColor === rgba(alpha[1]);
}), `${label}: badge source background and border tint`, assetCards.filter(card => card.badge).map(({ id, badgeStyle }) => ({ id, background: badgeStyle.backgroundColor, border: badgeStyle.borderTopColor })));
check(value.cards.every(card => card.avatarStyle.borderRadius === '3.5px' && card.avatarText === '' && card.photoState === 'empty'), `${label}: no-photo state is empty rectangular slot with no circular avatar or copied photo`, value.cards.map(({ id, avatarStyle, avatarText, photoState }) => ({ id, avatarStyle, avatarText, photoState })));
check(assetCards.some(card => card.kind === 'topLeader' && near(card.box.width, 146) && near(card.box.height, 74)) && first.length === 6 && first.every(card => near(card.box.width, 146) && near(card.box.height, 68)) && smaller.length > 0 && smaller.every(card => near(card.box.width, 108) && near(card.box.height, 52)), `${label}: approved representative/Depth1/deeper card sizes preserved`);
check(value.headers.filter(header => header.gap !== null).every(header => near(header.gap, 8)), `${label}: every organization header-to-first-card gap8`, value.headers.map(({ id, depth, gap }) => ({ id, depth, gap })));
check(value.headers.filter(header => header.depth >= 2).every(header => near(header.box.width, 114) && near(header.box.height, 30.4) && header.style.backgroundImage.startsWith('linear-gradient') && header.style.borderRadius === '7.1px' && header.style.textAlign === 'center'), `${label}: deeper group source header geometry and gradient`, value.headers.filter(header => header.depth >= 2));
const headerGradients = { 'business-management': ['rgb(25, 77, 130)', 'rgb(29, 57, 92)'], 'business-planning': ['rgb(33, 100, 168)', 'rgb(37, 73, 117)'], 'talent-growth': ['rgb(15, 106, 206)', 'rgb(4, 74, 148)'], 'integrated-system': ['rgb(140, 103, 182)', 'rgb(93, 52, 148)'], 'design-planning': ['rgb(9, 167, 162)', 'rgb(0, 132, 127)'], 'solution-sales': ['rgb(90, 150, 60)', 'rgb(63, 118, 41)'] };
check(value.headers.filter(header => header.depth >= 2).every(header => {
let id = header.id; while (depthOf(id) > 1) id = organizationById.get(id).parentId;
const expected = headerGradients[id], hr = id === 'talent-growth';
return expected?.every(color => header.style.backgroundImage.includes(color)) && header.style.fontSize === (hr ? '18px' : '20px') && header.style.fontWeight === (hr ? '600' : '700') && header.style.lineHeight === (hr ? '16.9px' : '19.7px');
}), `${label}: deeper headers inherit original parent color and source typography`, value.headers.filter(header => header.depth >= 2).map(({ id, style }) => ({ id, style })));
check(value.hub.paths === 3 && near(value.hub.iconBox?.width, 20) && near(value.hub.iconBox?.height, 20) && near(value.hub.gap, 8) && value.hub.style.fontSize === '21px' && value.hub.style.fontWeight === '750' && value.hub.style.lineHeight === '24px', `${label}: original hub icon and source text metrics`, value.hub);
check(value.hub.iconBox && value.hub.labelBox && [value.hub.iconBox, value.hub.labelBox].every(box => box.x >= value.hub.box.x - 0.5 && box.y >= value.hub.box.y - 0.5 && box.x + box.width <= value.hub.box.x + value.hub.box.width + 0.5 && box.y + box.height <= value.hub.box.y + value.hub.box.height + 0.5), `${label}: hub icon and label remain inside source pill`, value.hub);
check(value.status.every(group => group.heading === (group.status === 'dispatched' ? '파견' : '휴직')) && value.cards.filter(card => card.statusGroup).every(card => card.statusLabels === 0), `${label}: compact status headers retained and below-name labels removed`, value.status);
check(value.table?.title === '직위별 인원 현황 (팀별)' && JSON.stringify(value.table?.header) === JSON.stringify(['팀원', ...positions, '소계']) && JSON.stringify(value.table?.rows) === JSON.stringify(expectedRows) && JSON.stringify(value.table?.total) === JSON.stringify(expectedTotal) && fixture.employees.length === 41, `${label}: position matrix cross-counts original 41 employees`, { actual: value.table, expectedRows, expectedTotal });
check(value.bar && value.bar.titleCount === 0 && near(value.bar.box.height, 55.4) && value.bar.items.every(item => near(item.box.y, value.bar.items[0].box.y)) && value.bar.items.every(item => item.logoLoaded) && value.bar.items.every(item => item.count === (item.id === 'total' ? fixture.employees.length : fixture.employees.filter(employee => employee.affiliationId === item.id).length)), `${label}: untitled one-row affiliation bar height55.4 and original counts/logos`, value.bar);
check(value.cards.every(card => card.issues.length === 0), `${label}: card text has no horizontal overflow or element collisions`, value.cards.filter(card => card.issues.length).map(({ id, issues, textBoxes }) => ({ id, issues, textBoxes })));
check(value.collisions.length === 0 && value.moduleCollisions.length === 0 && value.outsideStage.length === 0, `${label}: cards/headers/modules do not overlap or fall outside stage`, { collisions: value.collisions, modules: value.moduleCollisions, outside: value.outsideStage });
check(value.document.scrollWidth <= value.document.clientWidth && value.document.scrollHeight <= value.document.clientHeight && value.screen.stage.right <= viewport.width + 1 && value.screen.stage.bottom <= viewport.height + 1 && value.screen.composition.width <= viewport.width && value.screen.composition.height >= value.screen.stage.height - 1, `${label}: proportionally fitted whole composition; no document scrolling or viewport clipping`, { scale: value.scale, baseWidth: value.baseWidth, baseHeight: value.baseHeight, document: value.document, screen: value.screen });
check(value.debugCount === 0 && value.cards.every(card => card.keyboardRole === 'button' && card.tabIndex === 0), `${label}: view-only cards retain keyboard information lookup without edit/collapse UI`, { debugCount: value.debugCount });
}
async function dialogs(page, label, viewport) {
await page.setViewportSize(viewport); await ready(page);
const targets = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const selectors = [
['representative', '.hm-orgchart-top-leaders .hm-orgchart-employee'],
['depth1', '.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'],
['depth2', '.node[data-organization-depth="2"] .hm-orgchart-employee[data-card-kind="leader"]'],
['employee', '.hm-orgchart-engine .hm-orgchart-employee[data-card-kind="employee"]'],
['dispatched', '.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'],
['onLeave', '.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee'],
];
return selectors.map(([kind, selector]) => ({ kind, selector, id: shadow.querySelector(selector)?.dataset.employeeId }));
});
for (const target of targets) {
if (!target.id) { check(false, `${label}: ${target.kind} dialog target exists`); continue; }
const trigger = page.locator(target.selector).first(), dialog = page.locator('dialog.hm-orgchart-employee-dialog[open]');
await trigger.click(); await dialog.waitFor({ state: 'visible' });
const value = await dialog.evaluate(element => {
const rect = element.getBoundingClientRect(), shadow = element.getRootNode();
const fields = [...element.querySelectorAll('dt')].map(term => [term.textContent, term.nextElementSibling.textContent]);
return { employeeId: element.dataset.employeeId, fields, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom }, insideStage: !!element.closest('.hm-orgchart-stage'), transform: getComputedStyle(element).transform, ariaModal: element.getAttribute('aria-modal'), focus: shadow.activeElement?.className, editCount: element.querySelectorAll('input,select,textarea,[contenteditable]').length };
});
interactions.push({ route: label, viewport, target: target.kind, ...value });
const employee = fixture.employees.find(employee => employee.id === target.id);
const fields = Object.fromEntries(value.fields);
check(value.employeeId === employee.id && fields['이름'] === '홍 길 동' && fields['직위'] === employee.position && fields['소속'] === organizationById.get(employee.organizationId)?.name && (!employee.jobTitle || fields['직책'] === employee.jobTitle) && value.fields.every(([field]) => ['이름', '직위', '소속', '직책', '관계사', '근무 상태'].includes(field)) && value.editCount === 0, `${label}/${viewport.width}: ${target.kind} dialog shows only real fixture fields`, value);
check(!value.insideStage && value.transform === 'none' && value.ariaModal === 'true' && value.rect.width >= Math.min(300, viewport.width - 32) && value.rect.right <= viewport.width + 1 && value.rect.bottom <= viewport.height + 1 && value.rect.x >= 0 && value.rect.y >= 0, `${label}/${viewport.width}: ${target.kind} modal remains unscaled and unclipped`, value.rect);
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
const trapped = await dialog.evaluate(element => element.contains(element.getRootNode().activeElement));
check(trapped && value.focus === 'hm-orgchart-employee-dialog-close', `${label}/${viewport.width}: ${target.kind} modal receives and traps keyboard focus`);
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' });
check(await trigger.evaluate(element => element.getRootNode().activeElement === element), `${label}/${viewport.width}: ${target.kind} ESC restores triggering card focus`);
await trigger.press('Enter'); await dialog.waitFor({ state: 'visible' });
await dialog.locator('.hm-orgchart-employee-dialog-close').click(); await dialog.waitFor({ state: 'hidden' });
check(await trigger.evaluate(element => element.getRootNode().activeElement === element), `${label}/${viewport.width}: ${target.kind} Enter opens; Close restores card focus`);
}
}
async function configure(page, transport, options) {
if (transport === 'react') {
await page.evaluate(({ links, callback }) => {
if (typeof window.OrgChartP1React?.setOptions !== 'function') throw new Error('React P1 example does not expose public-prop setOptions seam');
window.OrgChartP1React.setOptions({ organizationLinks: links, onEmployeeClick: callback ? (employee, context) => {
window.__p3CallbackEvents.push({ employeeId: employee.id, organizationId: context.organization?.id, affiliationId: context.affiliation?.id, triggerId: context.trigger.dataset.employeeId, eventType: context.originalEvent.type });
} : undefined });
}, options);
} else {
await page.evaluate(async ({ links, callback }) => {
const seam = window.OrgChartP1; seam.chart.destroy();
seam.chart = seam.api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new seam.api.JsonOrgChartDataSource(seam.data), stylesheetUrls: seam.stylesheetUrls, displayMode: 'readOnly', organizationLinks: links,
onEmployeeClick: callback ? (employee, context) => {
window.__p3CallbackEvents.push({ employeeId: employee.id, organizationId: context.organization?.id, affiliationId: context.affiliation?.id, triggerId: context.trigger.dataset.employeeId, eventType: context.originalEvent.type });
} : undefined });
await seam.chart.mount();
}, options);
}
await ready(page);
}
async function linksAndCallbacks(page, transport, routeUrl, targetUrl) {
const major = fixture.organizations.find(organization => depthOf(organization.id) === 1);
await page.evaluate(() => { window.__p3CallbackEvents = []; });
await configure(page, transport, { links: { [major.id]: targetUrl }, callback: true });
const info = await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
return { links: [...shadow.querySelectorAll('.hm-orgchart-organization-link')].map(link => ({ href: link.href, role: link.tagName, tabIndex: link.tabIndex, id: link.closest('.node').dataset.organizationId })), ordinaryTitles: [...shadow.querySelectorAll('.node[data-organization-depth="1"] .hm-orgchart-organization-name')].filter(header => !header.querySelector('a')).length };
});
check(info.links.length === 1 && info.links[0].id === major.id && info.links[0].href === targetUrl && info.links[0].role === 'A' && info.links[0].tabIndex === 0 && info.ordinaryTitles === 5, `${transport}: only explicitly mapped 1Depth title is a native link`, info);
const card = page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee');
await card.click();
await page.waitForFunction(() => window.__p3CallbackEvents.length === 1);
const first = await page.evaluate(() => ({ callbacks: window.__p3CallbackEvents, openDialogs: document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('dialog[open]').length }));
check(first.callbacks.length === 1 && first.openDialogs === 0 && first.callbacks[0].triggerId === first.callbacks[0].employeeId && first.callbacks[0].organizationId && first.callbacks[0].affiliationId, `${transport}: custom callback fires once with fixture/context and replaces default dialog`, first);
await card.press('Enter'); await card.press('Space');
const keyboard = await page.evaluate(() => window.__p3CallbackEvents);
check(keyboard.length === 3 && keyboard.slice(1).every(event => event.eventType === 'keydown'), `${transport}: Enter/Space each activate callback once`, keyboard);
await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.blur());
let reached = false;
for (let step = 0; step < 60 && !reached; step++) {
await page.keyboard.press('Tab');
reached = await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.classList.contains('hm-orgchart-organization-link') ?? false);
}
const linkFocus = await page.locator('.hm-orgchart-organization-link').evaluate(element => ({ matches: element.matches(':focus-visible'), outlineStyle: getComputedStyle(element).outlineStyle, outlineWidth: getComputedStyle(element).outlineWidth }));
check(reached && linkFocus.matches && linkFocus.outlineStyle === 'solid' && parseFloat(linkFocus.outlineWidth) >= 2, `${transport}: real Tab reaches title link with visible focus`, linkFocus);
check((await page.evaluate(() => window.__p3CallbackEvents)).length === 3, `${transport}: title focus does not activate employee callback`);
await Promise.all([page.waitForURL(targetUrl), page.keyboard.press('Enter')]);
check(await page.locator('#proof').textContent() === 'Configured organization link reached', `${transport}: native keyboard link reaches configured verifier URL`);
await page.goto(routeUrl); await ready(page);
await page.evaluate(() => { window.__p3CallbackEvents = []; });
await configure(page, transport, { links: {}, callback: false });
check(await page.locator('.hm-orgchart-organization-link').count() === 0, `${transport}: removing configured links restores ordinary titles`);
await page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee').click();
await page.locator('dialog[open]').waitFor({ state: 'visible' });
check(true, `${transport}: clearing custom callback restores default Core dialog`);
await page.keyboard.press('Escape');
}
function sameGeometry(a, b) {
if (!a || !b || a.cards.length !== b.cards.length || a.headers.length !== b.headers.length) return false;
const boxesEqual = (first, second) => ['x', 'y', 'width', 'height'].every(key => near(first[key], second[key], 0.4));
return a.cards.every((card, index) => ['id', 'kind', 'size', 'asset', 'name', 'role', 'badge'].every(key => card[key] === b.cards[index][key]) && boxesEqual(card.box, b.cards[index].box))
&& a.headers.every((header, index) => header.id === b.headers[index].id && boxesEqual(header.box, b.headers[index].box))
&& JSON.stringify(a.connectors) === JSON.stringify(b.connectors)
&& JSON.stringify(a.table && { ...a.table, box: undefined }) === JSON.stringify(b.table && { ...b.table, box: undefined })
&& a.table && b.table && boxesEqual(a.table.box, b.table.box);
}
let browser;
try {
const [htmlOrigin, reactOrigin] = await Promise.all([serve(path.join(root, 'examples/org-chart/dist')), serve(path.join(root, 'examples/org-chart-react/dist'))]);
// The user identified the live 6041 Storybook; never silently verify a stale
// storybook-static build from P2 instead of that requested running server.
const storybookOrigin = process.env.ORGCHART_STORYBOOK_URL?.replace(/\/$/, '') ?? 'http://127.0.0.1:6041';
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
const context = await browser.newContext({ deviceScaleFactor: 1, reducedMotion: 'reduce' });
const routes = [
{ label: 'html', transport: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly` },
{ label: 'file-classic', transport: 'html', url: `${pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href}?mode=readOnly` },
{ label: 'react', transport: 'react', url: `${reactOrigin}/p1.html?mode=readOnly` },
{ label: 'storybook', transport: null, url: `${storybookOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` },
];
for (const route of routes) {
const page = await context.newPage(); captureErrors(page, route.label);
try {
await page.setViewportSize(viewports[0]); await page.goto(route.url); await ready(page);
for (const viewport of viewports) {
await page.setViewportSize(viewport); await ready(page);
const value = await measure(page); measurements.push({ route: route.label, ...viewport, ...value });
verify(value, `${route.label}/${viewport.width}×${viewport.height}`, viewport);
if (viewport.width === 1920 || viewport.width === 390) {
const filename = `${route.label}-${viewport.width}x${viewport.height}.png`;
await page.screenshot({ path: path.join(output, filename), fullPage: true }); screenshots.push(filename);
}
}
await dialogs(page, route.label, viewports[0]);
await dialogs(page, route.label, viewports.at(-1));
if (route.label === 'html' || route.label === 'react') {
await page.setViewportSize(viewports[0]);
await linksAndCallbacks(page, route.transport, route.url, `${route.label === 'html' ? htmlOrigin : reactOrigin}/__p3_link_proof`);
}
} catch (error) { check(false, `${route.label}: P3 browser verification completed`, { message: error.message, stack: error.stack }); }
finally { await page.close(); }
}
for (const viewport of viewports) {
const values = ['html', 'file-classic', 'react', 'storybook'].map(route => measurements.find(item => item.route === route && item.width === viewport.width));
check(values.every(Boolean) && values.slice(1).every(value => sameGeometry(values[0], value)), `HTML/classic/React/Story shared Core geometry and matrix/${viewport.width}`, { routes: values.map(value => value?.route ?? 'missing'), basePixelTolerance: 0.4 });
}
const fitPage = await context.newPage(); captureErrors(fitPage, 'host-fit');
await fitPage.setViewportSize({ width: 1920, height: 650 }); await fitPage.goto(routes[0].url); await ready(fitPage);
const shortValue = await measure(fitPage); measurements.push({ route: 'short-height', width: 1920, height: 650, ...shortValue });
check(shortValue.document.scrollHeight <= shortValue.document.clientHeight && shortValue.screen.stage.bottom <= 650 && shortValue.scale <= (650 - shortValue.screen.composition.y) / shortValue.baseHeight + 0.002, 'short1920×650: fit respects actual visible height without assuming host header', shortValue.screen);
await fitPage.setViewportSize(viewports[0]);
await fitPage.evaluate(() => { document.getElementById('chart-host').style.height = '400px'; }); await ready(fitPage);
const fixed = await measure(fitPage); measurements.push({ route: 'fixed400-host', width: 1920, height: 1080, ...fixed });
check(fixed.screen.stage.height <= 400.5 && fixed.screen.composition.height <= 400.5 && fixed.scale <= 400 / fixed.baseHeight + 0.002 && fixed.screen.stage.bottom <= fixed.screen.host.y + 400.5, 'explicit400px container: fit uses host height without clipping whole composition', { scale: fixed.scale, screen: fixed.screen });
await fitPage.evaluate(() => {
const host = document.getElementById('chart-host'); host.style.height = ''; host.style.maxHeight = '400px';
}); await ready(fitPage);
const maxHeight = await measure(fitPage); measurements.push({ route: 'max-height400-host', width: 1920, height: 1080, ...maxHeight });
check(maxHeight.screen.stage.height <= 400.5 && maxHeight.screen.composition.height <= 400.5 && maxHeight.scale <= 400 / maxHeight.baseHeight + 0.002, 'max-height400px container: fit respects ancestor max-height', { scale: maxHeight.scale, screen: maxHeight.screen });
await fitPage.evaluate(() => {
const host = document.getElementById('chart-host'); host.style.maxHeight = ''; host.style.height = '400px'; host.style.boxSizing = 'border-box'; host.style.padding = '20px';
const chartRoot = host.querySelector('.hm-orgchart-host'); const inner = document.createElement('div'); inner.style.padding = '12px'; inner.style.boxSizing = 'border-box';
host.append(inner); inner.append(chartRoot);
}); await ready(fitPage);
const padded = await measure(fitPage); measurements.push({ route: 'nested-padding400-host', width: 1920, height: 1080, ...padded });
check(padded.screen.stage.height <= 336.5 && padded.scale <= 336 / padded.baseHeight + 0.002 && padded.screen.stage.bottom <= padded.screen.host.y + 336.5, '400px border-box ancestor: fit subtracts20px outer and12px inner vertical padding', { scale: padded.scale, screen: padded.screen });
await fitPage.close();
const index = await (await fetch(`${storybookOrigin}/index.json`)).json();
const entries = Object.values(index.entries ?? {});
const visible = entries.filter(entry => entry.title === 'OrgChart' && entry.type === 'story' && !entry.tags?.includes('internal'));
check(JSON.stringify(visible.map(entry => entry.name).sort()) === JSON.stringify(['Logo · Personnel Summary', 'Department Colors', 'Full Composition'].sort()), 'Storybook index: exactly three user-facing OrgChart pages', visible.map(({ id, name, tags }) => ({ id, name, tags })));
check(['orgchart--logo', 'orgchart--position-summary', 'orgchart--affiliation-summary'].every(id => entries.some(entry => entry.id === id && entry.tags?.includes('internal'))), 'Storybook index: independent component verification Stories retained');
const combined = await context.newPage(); captureErrors(combined, 'combined-summary');
await combined.goto(`${storybookOrigin}/iframe.html?id=orgchart--logo-personnel-summary&viewMode=story`);
await combined.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-component-host')].filter(host => host.shadowRoot?.querySelector('[data-component]')).length >= 3);
const combinedContent = await combined.evaluate(() => [...document.querySelectorAll('.hm-orgchart-component-host')].map(host => [...host.shadowRoot.querySelectorAll('[data-component]')].map(element => element.dataset.component)).flat());
check(['Logo', 'PositionSummary', 'AffiliationSummary'].every(name => combinedContent.includes(name)), 'Storybook combined page renders all three existing Core components', combinedContent);
await combined.close();
const manager = await context.newPage(); captureErrors(manager, 'storybook-manager');
await manager.goto(`${storybookOrigin}/?path=/story/orgchart--full-composition`);
await manager.locator('a[href*="orgchart--full-composition"]').first().waitFor({ state: 'visible', timeout: 20000 });
const sidebar = await manager.evaluate(() => [...document.querySelectorAll('a[href*="/story/orgchart--"]')].filter(element => element.getBoundingClientRect().width && element.getBoundingClientRect().height).map(element => ({ name: element.textContent.trim(), href: element.getAttribute('href') })));
check(sidebar.length === 3 && ['logo-personnel-summary', 'department-colors', 'full-composition'].every(id => sidebar.some(item => item.href.includes(`orgchart--${id}`))), 'Storybook manager: actual visible OrgChart sidebar contains only three pages', sidebar);
await manager.close();
const interactivePage = await context.newPage(); captureErrors(interactivePage, 'interactive');
await interactivePage.goto(`${htmlOrigin}/p1.html`); await ready(interactivePage, 'interactive');
const preserved = await interactivePage.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
return { toggles: shadow.querySelectorAll('.hm-orgchart-toggle').length, visibleRoutes: [...shadow.querySelectorAll('.hm-orgchart-engine .hierarchy')].some(element => !['none', 'normal'].includes(getComputedStyle(element, '::before').content) && getComputedStyle(element, '::before').display !== 'none'), statusLabels: shadow.querySelector('.hm-orgchart-status-group .hm-orgchart-status-label')?.textContent };
});
check(preserved.toggles > 0 && preserved.visibleRoutes && ['파견', '휴직'].includes(preserved.statusLabels), 'interactive view preserves existing collapse, hierarchy routes and status labels', preserved);
await interactivePage.close();
check(createHash('sha256').update(await readFile(fixturePath)).digest('hex') === fixtureHash, 'P3 verifier preserves original 41-person fixture bytes', { sha256: fixtureHash });
check(errors.length === 0, 'P3 routes have no browser runtime/console errors', errors);
} catch (error) { check(false, 'P3 verifier setup/completion', { message: error.message, stack: error.stack }); }
finally {
await writeFile(path.join(output, 'browser.json'), JSON.stringify({
browser: browser?.version(), scope: 'P3 impacts only; P2/old suites and expectations are unchanged', fixtureSha256: fixtureHash,
transportPolicy: 'HTTP ESM/React/Storybook and true file:// classic without file-access Chrome flags',
testLinkPolicy: '/__p3_link_proof URLs exist only in the verifier-owned local HTTP servers; no actual product URL/data was invented',
connectorRoutingInk: 'NOT VERIFIED automatically: inspect representative screenshots for curved route/ink intersections',
textInkCollision: 'NOT VERIFIED automatically: confirmed gap0 may overlap font envelopes; measured element boxes and horizontal text limits are checked',
originalPhotoPolicy: 'Workdesk has rectangular photo slots and no explicit missing-photo asset. Fictitious cards use transparent empty slots, no real employee photo copied.',
results, errors, measurements, interactions, screenshots,
}, null, 2));
await browser?.close();
for (const server of servers) await new Promise(resolve => server.close(resolve));
}
const failures = results.filter(result => result.status === 'FAIL');
console.log(JSON.stringify({ output, checks: results.length, failureCount: failures.length, failures: failures.slice(0, 30).map(result => result.name), ...(failures.length > 30 ? { additionalFailures: failures.length - 30 } : {}) }, null, 2));
if (failures.length) process.exitCode = 1;
+43
View File
@@ -0,0 +1,43 @@
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-consumer');
const packageRoot = path.join(consumer, 'node_modules/@hm-design/org-chart');
await mkdir(packageRoot, { recursive: true });
execFileSync('tar.exe', ['-xzf', path.join(root, 'test-results/hm-design-org-chart-0.1.0.tgz'), '-C', packageRoot, '--strip-components=1']);
await writeFile(path.join(consumer, 'package.json'), JSON.stringify({name:'org-chart-isolated-consumer',private:true,type:'module'}));
const manifest = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'));
assert.deepEqual(manifest.dependencies, {orgchart:'6.0.0'});
assert.equal(manifest.peerDependencies, undefined);
const consumerRequire = createRequire(path.join(consumer, 'package.json'));
for (const subpath of ['', '/style.css', '/engine.css', '/browser']) await readFile(consumerRequire.resolve(`@hm-design/org-chart${subpath}`));
const module = await import(pathToFileURL(consumerRequire.resolve('@hm-design/org-chart')).href);
assert.equal(typeof module.createOrgChart, 'function');
assert.equal(typeof module.JsonOrgChartDataSource, 'function');
const fixture = JSON.parse(await readFile(new URL('data-v1.json', import.meta.url), 'utf8'));
assert.equal(module.validateOrgChartDataset(fixture).valid, true);
assert.equal(module.calculateSummary(fixture).raw.total, 4);
for (const file of ['index.js','orgchart.browser.js']) {
const bundle = await readFile(path.join(packageRoot, 'dist', file), 'utf8');
assert.ok(!/\b(?:import|export)\s+[^;\n]*?from\s*['"](?!\.)/.test(bundle));
assert.ok(!/\bimport\s*\(\s*['"](?!\.)/.test(bundle));
assert.ok(!/from\s*['"]react(?:-dom)?['"]/.test(bundle));
}
await writeFile(path.join(consumer, 'consumer.ts'), `import { createOrgChart, JsonOrgChartDataSource, type OrgChartDataset, type OrgChartDataSource } from '@hm-design/org-chart';
declare const data: OrgChartDataset;
const source: OrgChartDataSource = new JsonOrgChartDataSource(data);
const handle = createOrgChart({dataSource:source,stylesheetUrls:{engine:'engine.css',core:'style.css'}});
const stop = handle.subscribe(state=>{if(state.status==='ready') console.log(state.summary.raw.total);});
stop(); handle.destroy();\n`);
await writeFile(path.join(consumer, 'tsconfig.json'), JSON.stringify({compilerOptions:{target:'ES2022',module:'NodeNext',moduleResolution:'NodeNext',lib:['ES2022','DOM'],strict:true,noEmit:true,types:[],skipLibCheck:false},include:['consumer.ts']}));
const require = createRequire(import.meta.url);
const tscManifest = require.resolve('typescript/package.json');
const typescript = JSON.parse(await readFile(tscManifest, 'utf8'));
execFileSync(process.execPath, [path.resolve(path.dirname(tscManifest), typescript.bin.tsc), '-p', path.join(consumer,'tsconfig.json')], {stdio:'inherit'});
const report = {status:'PASS',checks:['Tarball public ESM import without DOM','Public CSS/browser entries exist','Strict NodeNext public declarations without React types','No unresolved bare imports in browser/ESM bundles','Tarball validated fixture and summary','No React peer/runtime dependency'],consumer};
await writeFile(path.join(consumer,'report.json'), JSON.stringify(report,null,2));
console.log(JSON.stringify(report,null,2));
@@ -0,0 +1,38 @@
import { writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href);
const browser = await chromium.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.goto('http://127.0.0.1:6027/iframe.html?id=orgchart--full-composition&viewMode=story');
await page.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot?.querySelector('.hm-orgchart-toggle'));
const result = await page.evaluate(async () => {
const root = document.querySelector('.hm-orgchart-host').shadowRoot;
await document.fonts.ready;
const shell = root.querySelector('.hm-orgchart-shell');
const read = (element, pseudo) => {
const css = getComputedStyle(element, pseudo);
return { background: css.backgroundColor, border: css.borderTopColor, borderWidth: css.borderTopWidth, outline: css.outlineColor, outlineWidth: css.outlineWidth, outlineStyle: css.outlineStyle, content: css.content };
};
const hierarchy = [...root.querySelectorAll('.hierarchy')].find(element => getComputedStyle(element, '::before').content !== 'none');
const node = [...root.querySelectorAll('.node')].find(element => getComputedStyle(element, '::before').content !== 'none');
const backgroundChain = [];
for (let element = root.querySelector('.hm-orgchart-engine'); element; element = element.parentElement ?? element.getRootNode()?.host) {
const css = getComputedStyle(element);
backgroundChain.push({ className: element.className, tag: element.tagName, background: css.backgroundColor, image: css.backgroundImage });
}
return { canvas: getComputedStyle(shell).backgroundColor, viewport: read(root.querySelector('.hm-orgchart-viewport')),
backgroundChain,
hierarchy: hierarchy ? read(hierarchy, '::before') : null, node: node ? read(node, '::before') : null,
toggle: read(root.querySelector('.hm-orgchart-toggle')), resolved: Object.fromEntries(['stroke-default', 'stroke-input-focus', 'bg-content', 'bg-surface'].map(key => [key, getComputedStyle(shell).getPropertyValue('--hm-orgchart-resolved-' + key).trim()])) };
});
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => {
const root = document.querySelector('.hm-orgchart-host').shadowRoot;
const viewport = root.querySelector('.hm-orgchart-viewport'); viewport.focus();
const css = getComputedStyle(viewport);
return { focusVisible: viewport.matches(':focus-visible'), outline: css.outlineColor, width: css.outlineWidth, style: css.outlineStyle };
});
await writeFile('test-results/org-chart-quality/boundaries.json', JSON.stringify({ ...result, focused, interpretation: 'Hierarchy connection lines convey parent-child relation; retain contrast REVIEW instead of automatically treating all lines as decorative. Button text identifies the control, frame decoration alone is not automatically SC 1.4.11 failure.' }, null, 2));
console.log(JSON.stringify({ ...result, focused }));
} finally { await browser.close(); }
+183
View File
@@ -0,0 +1,183 @@
import http from 'node:http';
import path from 'node:path';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
// Quality-only verification. No data/source/lifecycle regression suite is run.
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-quality');
await mkdir(output, { recursive: true });
const { chromium } = await import(process.env.ORGCHART_PLAYWRIGHT_MODULE ? pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href : 'playwright');
const results = [], errors = [], measurements = [], screenshots = [];
const check = (value, name, detail) => results.push({ name, status: value ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.ttf': 'font/ttf' };
async function serve(directory) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1');
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
const relative = path.relative(directory, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
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}` };
}
function captureErrors(page, label) {
page.on('pageerror', error => errors.push({ label, type: 'pageerror', message: error.message }));
page.on('console', message => { if (message.type() === 'error') errors.push({ label, type: 'console', message: message.text() }); });
}
async function ready(page) {
await page.waitForFunction(() => {
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
return shadow?.querySelector('.hm-orgchart-shell') && [...shadow.querySelectorAll('link')].every(link => !!link.sheet)
&& !shadow.querySelector('.hm-orgchart-state')?.textContent.includes('불러오는');
}, undefined, { timeout: 20000 });
await page.evaluate(async () => {
await document.fonts.ready;
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
await Promise.all([...shadow.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(image => image.decode().catch(() => {})));
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
}
async function measure(page) {
return page.evaluate(() => {
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host');
const shadow = host.shadowRoot;
const rect = element => {
const box = element.getBoundingClientRect();
return { x: box.x + scrollX, y: box.y + scrollY, width: box.width, height: box.height };
};
const ranges = element => {
const range = document.createRange(); range.selectNodeContents(element);
return [...range.getClientRects()].map(box => ({ x: box.x + scrollX, y: box.y + scrollY, width: box.width, height: box.height }));
};
const dimensions = element => element ? { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, overflowX: getComputedStyle(element).overflowX } : null;
const visible = element => element.getBoundingClientRect().width > 0 && element.getBoundingClientRect().height > 0;
const text = [...shadow.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-organization-name, .hm-orgchart-contrast-result, .hm-orgchart-affiliation-name')].filter(visible);
const textIssues = text.filter(element => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1).map(element => ({ className: element.className, text: element.textContent, box: rect(element) }));
const styles = element => {
const css = getComputedStyle(element);
return { color: css.color, background: css.backgroundColor, gradient: css.backgroundImage, font: css.fontFamily, fontSize: parseFloat(css.fontSize), fontWeight: css.fontWeight, border: css.borderTopColor, radius: css.borderRadius };
};
const backgroundFor = element => {
for (let current = element; current; current = current.parentElement) {
const css = getComputedStyle(current);
if (css.backgroundImage !== 'none') return { ...styles(current), box: rect(current) };
if (css.backgroundColor !== 'rgba(0, 0, 0, 0)' && css.backgroundColor !== 'transparent') return { ...styles(current), box: rect(current) };
}
return null;
};
const selectors = '.hm-orgchart-organization-name, .hm-orgchart-hub, [data-contrast-target], .hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title, .hm-orgchart-status-label, .hm-orgchart-summary-title, .hm-orgchart-position-table th, .hm-orgchart-position-table td, .hm-orgchart-toggle';
const colors = [...shadow.querySelectorAll(selectors)].filter(visible).map(element => ({
className: element.className, organizationId: element.closest('[data-organization-id]')?.dataset.organizationId,
variant: element.closest('[data-group-variant]')?.dataset.groupVariant,
target: element.dataset.contrastTarget ?? null,
text: element.textContent.trim(), box: rect(element), ranges: ranges(element), ...styles(element), behind: backgroundFor(element),
clip: element.closest('.hm-orgchart-viewport, .hm-orgchart-summary-scroll') ? rect(element.closest('.hm-orgchart-viewport, .hm-orgchart-summary-scroll')) : null,
}));
const logos = [...shadow.querySelectorAll('.hm-orgchart-affiliation-mark')].map(mark => {
const image = mark.querySelector('img');
return { id: mark.dataset.affiliationId, label: mark.getAttribute('aria-label'), box: rect(mark),
available: mark.dataset.logoAvailable, fallbackVisibility: getComputedStyle(mark.querySelector('.hm-orgchart-affiliation-fallback')).visibility,
loaded: !!image?.complete && image.naturalWidth > 0, naturalWidth: image?.naturalWidth, naturalHeight: image?.naturalHeight,
fit: image ? getComputedStyle(image).objectFit : null, imageBackground: image ? getComputedStyle(image).backgroundColor : null,
source: image?.src ?? null };
});
const cardStyles = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({ id: element.dataset.employeeId, kind: element.dataset.cardKind, ...styles(element) }));
return { documentWidth: document.documentElement.scrollWidth, textIssues, viewport: dimensions(shadow.querySelector('.hm-orgchart-viewport')),
summary: dimensions(shadow.querySelector('.hm-orgchart-summary-scroll')), logos, colors, cardStyles,
rowResults: [...shadow.querySelectorAll('.hm-orgchart-contrast-result')].map(element => ({ text: element.textContent, ...element.dataset })),
policy: [...shadow.querySelectorAll('.hm-orgchart-policy')].map(element => element.textContent),
components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component),
variables: [...shadow.querySelectorAll('[data-color-variable]')].map(element => ({ variable: element.dataset.colorVariable, property: element.dataset.cssProperty, computed: getComputedStyle(element).getPropertyValue(element.dataset.cssProperty), value: element.parentElement.querySelector('.hm-orgchart-color-value').textContent })),
navigation: shadow.querySelectorAll('nav, .hm-dashboard-header, .hm-gnb, .hm-lnb, [data-slot=sidebar]').length };
});
}
function compactSources(measurement) {
for (const logo of measurement.logos) {
logo.sha256 = logo.source?.startsWith('data:image/png;base64,') ? createHash('sha256').update(Buffer.from(logo.source.split(',')[1], 'base64')).digest('hex') : null;
delete logo.source;
}
return measurement;
}
let browser;
const servers = [];
try {
for (const directory of ['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist']) servers.push(await serve(path.join(root, directory)));
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1, reducedMotion: 'reduce' });
const canonical = ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'];
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
const menu = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart') && !entry.tags?.includes('internal')).map(entry => entry.id).sort();
check(JSON.stringify(menu) === JSON.stringify(canonical.map(name => `orgchart--${name}`).sort()), 'final build retains exactly five public OrgChart menu entries', menu);
const internal = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title === 'OrgChart/Content');
check(internal.length === 8 && internal.every(entry => entry.tags.includes('internal')), 'original eight verification Stories remain tagged internal');
const sourceHashes = new Map();
for (const id of ['hanmac', 'saman', 'jangheon', 'jangheon-industry', 'ptc', 'halla', 'baron']) sourceHashes.set(id, createHash('sha256').update(await readFile(path.join(root, `packages/org-chart/src/assets/affiliations/${id}.png`))).digest('hex'));
for (const name of canonical) {
const page = await context.newPage(); captureErrors(page, name);
await page.goto(`${servers[0].origin}/iframe.html?id=orgchart--${name}&viewMode=story`); await ready(page);
if (name.endsWith('summary')) check(await page.locator('body').innerText().then(text => text.includes('가상') && text.includes('운영')), `${name}: independent Story identifies virtual headcounts`);
for (const width of name === 'full-composition' ? [390, 768, 1280, 1440] : [390, 1440]) {
await page.setViewportSize({ width, height: 1000 }); await ready(page);
const value = compactSources(await measure(page)); value.label = `storybook-${name}`; value.width = width;
measurements.push(value);
check(value.documentWidth <= width + 1 && value.textIssues.length === 0, `${name} ${width}px: changed content has no page overflow or clipped text`, value.textIssues);
check(value.navigation === 0, `${name} ${width}px: no page Navigation added`);
if (value.logos.length) check(value.logos.every(logo => logo.loaded && logo.fit === 'contain' && logo.naturalWidth === 473 && logo.naturalHeight === 473 && logo.sha256 === sourceHashes.get(logo.id) && logo.imageBackground === 'rgba(0, 0, 0, 0)' && logo.fallbackVisibility === 'hidden'), `${name} ${width}px: original transparent logos and ID mappings are preserved`);
const file = `${value.label}-${width}.png`; value.screenshot = file;
await page.screenshot({ path: path.join(output, file), fullPage: true }); screenshots.push(file);
}
if (name === 'department-colors') {
const value = measurements.at(-1);
check(value.variables.length === 30 && value.variables.every(item => item.value.includes('#')), 'Department Colors retains all six HEX and variable legends');
check(value.rowResults.length >= 18 && value.rowResults.every(item => item.contrastStatus && !item.text.includes('NaN')), 'Department Colors exposes current and unapplied candidate contrast results', value.rowResults);
}
if (name === 'logo') {
const result = await page.evaluate(async () => {
const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot;
const mark = shadow.querySelector('.hm-orgchart-affiliation-mark');
const image = mark.querySelector('img');
const fallback = mark.querySelector('.hm-orgchart-affiliation-fallback');
const before = { label: mark.getAttribute('aria-label'), visibility: getComputedStyle(fallback).visibility };
image.dispatchEvent(new Event('error'));
return { before, after: { label: mark.getAttribute('aria-label'), visibility: getComputedStyle(fallback).visibility, available: mark.dataset.logoAvailable, text: fallback.textContent, imageRemoved: !mark.querySelector('img') } };
});
check(result.before.visibility === 'hidden' && result.after.visibility === 'visible' && result.after.available === 'false' && result.after.text && result.after.imageRemoved && result.after.label === result.before.label, 'transparent logo change retains text fallback and accessible company name on error', result);
}
await page.close();
}
for (const [transport, server] of [['html', servers[1]], ['react', servers[2]]]) {
const page = await context.newPage(); captureErrors(page, transport);
await page.goto(`${server.origin}/p1.html`); await ready(page);
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1000 }); await ready(page);
const value = compactSources(await measure(page)); value.label = transport; value.width = width; measurements.push(value);
check(value.documentWidth <= width + 1 && value.textIssues.length === 0 && value.navigation === 0, `${transport} ${width}px: logo style change preserves layout and text`);
check(value.logos.every(logo => logo.loaded && logo.fit === 'contain' && logo.imageBackground === 'rgba(0, 0, 0, 0)' && logo.fallbackVisibility === 'hidden'), `${transport} ${width}px: transparent logo layer applied inside Shadow DOM`);
const file = `${transport}-${width}.png`; value.screenshot = file;
await page.screenshot({ path: path.join(output, file), fullPage: true }); screenshots.push(file);
}
await page.close();
}
for (const width of [390, 768, 1280, 1440]) {
const html = measurements.find(item => item.label === 'html' && item.width === width);
const react = measurements.find(item => item.label === 'react' && item.width === width);
check(JSON.stringify(html.cardStyles) === JSON.stringify(react.cardStyles) && JSON.stringify(html.logos.map(logo => [logo.id, logo.sha256, logo.fit, logo.imageBackground])) === JSON.stringify(react.logos.map(logo => [logo.id, logo.sha256, logo.fit, logo.imageBackground])), `${width}px: HTML and React retain identical Core card colors and logo rendering`);
}
check(errors.length === 0, 'changed five Stories and theme-free HTML/React emit no console/runtime errors', errors);
} catch (error) { results.push({ name: 'quality harness completion', status: 'FAIL', detail: error.stack }); }
finally {
await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version() ?? null, results, errors, measurements, screenshots,
reused: ['Core22/22', 'HTML69/69', 'React79/79', 'P1 selector7/7', 'P1 successful196 plus console/menu4/4'],
limits: ['No data/lifecycle regression suites repeated', 'Contrast model and pixel samples are reported separately; no all-screen WCAG certification'] }, null, 2));
if (browser) await browser.close();
for (const { server } of servers) await new Promise(resolve => server.close(resolve));
const failed = results.filter(result => result.status === 'FAIL');
if (failed.length) process.exitCode = 1;
console.log(JSON.stringify({ passed: results.filter(result => result.status === 'PASS').length, failed, report: path.join(output, 'browser.json') }, null, 2));
}
+218
View File
@@ -0,0 +1,218 @@
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));