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') } } },
|
||||
});
|
||||
Reference in new issue
Block a user