feat(org-chart): add core cards and fixed-layout integration
This commit is contained in:
1 parent
d18e3c3380
commit
61c6f8d1f1
161 files changed
+13044
-1
No files matched your search
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
@@ -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') }));
|
||||
}
|
||||
@@ -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') } } },
|
||||
});
|
||||
@@ -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']))
|
||||
@@ -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))
|
||||
@@ -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');
|
||||
@@ -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`);
|
||||
@@ -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 정보와 무관한 가상 구성"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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}`);
|
||||
@@ -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}`));
|
||||
@@ -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;
|
||||
@@ -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)); }
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
@@ -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(); }
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
Reference in new issue
Block a user