Files
HMDesign/scripts/verify-orgchart-minimal-install.mjs
T

226 lines
15 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { mkdir, mkdtemp, readFile, writeFile, copyFile, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
// External consumers are outside the checkout and its node_modules ancestors.
// Preserve the temp projects, lockfiles and logs for inspection; never clean them.
const root = fileURLToPath(new URL('../', import.meta.url));
const evidence = path.join(root, 'test-results/org-chart-minimal-install');
await mkdir(evidence, { recursive: true });
const npmCli = process.env.ORGCHART_NPM_CLI || path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js');
const externalRoot = process.env.ORGCHART_CONSUMER_ROOT || await mkdtemp(path.join(tmpdir(), 'hm-orgchart-minimal-'));
const projects = Object.fromEntries(['A', 'B', 'C'].map(name => [name, path.join(externalRoot, name)]));
await writeFile(path.join(evidence, 'projects.json'), JSON.stringify({ externalRoot, projects }, null, 2));
console.log(`External projects: ${externalRoot}`);
function run(args, cwd, log) {
const result = spawnSync(process.execPath, args, { cwd, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 });
return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}`, error: result.error, log };
}
async function npm(args, cwd, log) {
const result = run([npmCli, ...args], cwd, log);
await writeFile(path.join(evidence, `${log}.log`), result.output);
assert.equal(result.status, 0, `${log} failed: ${result.output.slice(-5000)}`);
console.log(`${log}: PASS`);
return result;
}
if (process.env.ORGCHART_SKIP_PACK !== '1') for (const name of ['org-chart', 'ui', 'org-chart-react']) {
await npm(['pack', '--workspace', `@hm-design/${name}`, '--pack-destination', evidence], root, `pack-${name}`);
}
const tarball = name => `file:${path.join(evidence, `hm-design-${name}-0.1.0.tgz`).replaceAll('\\', '/')}`;
const versions = { react: '19.3.0', 'react-dom': '19.3.0' };
for (const [name, directory] of Object.entries(projects)) {
await mkdir(directory, { recursive: true });
const manifest = {
name: `hm-orgchart-minimal-${name.toLowerCase()}`, private: true, type: 'module',
scripts: { typecheck: 'tsc --noEmit', build: 'npm run typecheck && vite build' },
dependencies: { '@hm-design/org-chart': tarball('org-chart'), ...(name === 'A' ? {} : { ...versions, '@hm-design/org-chart-react': tarball('org-chart-react') }), ...(name === 'C' ? { '@hm-design/ui': tarball('ui'), tailwindcss: '4.3.3' } : {}) },
devDependencies: { typescript: '7.0.2', vite: '8.3.0', ...(name === 'A' ? {} : { '@types/react': '19.3.0', '@types/react-dom': '19.3.0' }), ...(name === 'C' ? { '@tailwindcss/vite': '4.3.3' } : {}) },
};
await writeFile(path.join(directory, 'package.json'), JSON.stringify(manifest, null, 2));
await writeFile(path.join(directory, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022', lib: ['ES2022', 'DOM', 'DOM.Iterable'], module: 'ESNext', moduleResolution: 'Bundler', jsx: 'react-jsx', strict: true, skipLibCheck: false, noEmit: true, types: ['vite/client'], resolveJsonModule: true }, include: ['main.ts', 'main.tsx'] }, null, 2));
await copyFile(path.join(root, 'examples/org-chart/data-expanded-small.json'), path.join(directory, 'data.json'));
await writeFile(path.join(directory, 'index.html'), '<!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>OrgChart package verification</title></head><body><div id="app"></div><script type="module" src="/main.' + (name === 'A' ? 'ts' : 'tsx') + '"></script></body></html>');
const shared = `import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart';
import engine from '@hm-design/org-chart/engine.css?url';
import core from '@hm-design/org-chart/style.css?url';
import data from './data.json';
const source = new JsonOrgChartDataSource(data);
const stylesheetUrls = { engine, core };
const params = new URLSearchParams(location.search);
const layoutMode = params.get('layout') === 'fixed' ? 'fixed' : 'auto';
const events: unknown[] = [], errors: string[] = [];
const onStateChange = (state: import('@hm-design/org-chart').OrgChartState) => { events.push(state); document.documentElement.dataset.chartState = state.status; };
const onLifecycleError = (error: unknown) => errors.push(String(error));
Object.assign(window, { verification: { events, errors, dataBefore: JSON.stringify(data), data } });
document.body.style.margin = '0';
`;
if (name === 'A') {
await writeFile(path.join(directory, 'main.ts'), shared + `const handle = createOrgChart({dataSource: source, stylesheetUrls, layoutMode, displayMode: 'readOnly', onStateChange});
Object.assign(window, { handle });
await handle.mount(document.getElementById('app')!);
window.addEventListener('pagehide', () => handle.destroy());
`);
} else {
await writeFile(path.join(directory, 'main.tsx'), shared.replace('createOrgChart, ', '') + `import { StrictMode, useLayoutEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { OrgChart as BaseOrgChart, type OrgChartEmployeePopupProps, type OrgChartProps } from '@hm-design/org-chart-react';
${name === 'C' ? "import { OrgChart as HMOrgChart } from '@hm-design/org-chart-react/hm-dialog';\nimport './hm.css';" : ''}
function CustomPopup({selection, onClose}: OrgChartEmployeePopupProps) {
useLayoutEffect(() => { if (!selection) return; const trigger = selection.context.trigger; return () => { if (trigger.isConnected) trigger.focus(); }; }, [selection]);
return selection ? <div role="dialog" aria-modal="true" aria-label="Custom employee" data-component="CustomPopup"><p>{selection.employee.name}</p><button onClick={onClose}>Close custom</button></div> : null;
}
function App() {
const [mode, setMode] = useState<NonNullable<OrgChartProps['employeePopup']>>((params.get('popup') as OrgChartProps['employeePopup']) || 'core');
const [callback, setCallback] = useState(false);
const Chart = ${name === 'C' ? "params.get('entry') === 'base' ? BaseOrgChart : HMOrgChart" : 'BaseOrgChart'};
Object.assign(window, { setPopup: setMode, setCallback, source });
return <Chart dataSource={source} stylesheetUrls={stylesheetUrls} layoutMode={layoutMode} displayMode="readOnly"
employeePopup={mode} employeePopupAdapter={mode === 'adapter' || params.get('adapter') === 'supplied' ? CustomPopup : undefined}
onEmployeeClick={callback ? (employee, context) => { Object.assign(window, { clicked: { employee, organization: context.organization } }); } : undefined}
onStateChange={onStateChange} onLifecycleError={onLifecycleError} />;
}
createRoot(document.getElementById('app')!).render(<StrictMode><App /></StrictMode>);
`);
if (name === 'C') {
await writeFile(path.join(directory, 'hm.css'), '@import "tailwindcss";\n@import "@hm-design/ui/theme.css";\n@source "./node_modules/@hm-design/ui/dist";\n');
await writeFile(path.join(directory, 'vite.config.mjs'), "import tailwindcss from '@tailwindcss/vite'; export default { plugins: [tailwindcss()] };\n");
}
}
await npm(['install', ...(process.env.ORGCHART_OFFLINE === '1' ? ['--offline'] : []), '--no-audit', '--no-fund'], directory, `install-${name}`);
const require = createRequire(path.join(directory, 'package.json'));
assert.ok(!require.resolve('@hm-design/org-chart').includes(root));
if (name !== 'C') {
assert.throws(() => require.resolve('@hm-design/ui'), { code: 'MODULE_NOT_FOUND' });
await assert.rejects(access(path.join(directory, 'node_modules/@hm-design/ui')));
}
if (name === 'A') {
assert.throws(() => require.resolve('react'), { code: 'MODULE_NOT_FOUND' });
assert.throws(() => require.resolve('@hm-design/org-chart-react'), { code: 'MODULE_NOT_FOUND' });
}
await npm(['ls', '--all', '--json'], directory, `dependencies-${name}`);
await npm(['run', 'build'], directory, `build-${name}`);
}
// Traverse the shipped default runtime and declaration graph, including shared chunks.
const baseDir = path.join(projects.B, 'node_modules/@hm-design/org-chart-react/dist');
const graph = [];
async function inspect(file) {
if (graph.includes(file)) return;
graph.push(file);
const text = await readFile(path.join(baseDir, file), 'utf8');
assert.ok(!text.includes('@hm-design/ui') && !text.includes('HMEmployeeDialog') && !text.includes('hm-dialog.js'), `UI leaked into ${file}`);
for (const match of text.matchAll(/(?:from\s*|import\s*\()?["'](\.\/[^"']+\.(?:js|ts))["']/g)) {
let relative = match[1].slice(2);
if (file.endsWith('.d.ts') && relative.endsWith('.js')) relative = relative.slice(0, -3) + '.d.ts';
await inspect(relative);
}
}
await inspect('index.js'); await inspect('index.d.ts');
await writeFile(path.join(evidence, 'default-entry-graph.json'), JSON.stringify({ status: 'PASS', files: graph }, null, 2));
const playwrightPath = process.env.ORGCHART_PLAYWRIGHT_MODULE;
assert.ok(playwrightPath, 'Set ORGCHART_PLAYWRIGHT_MODULE to an existing Playwright entry (test-only).');
const { chromium } = await import(pathToFileURL(playwrightPath).href);
const browser = await chromium.launch({ headless: true, executablePath: process.env.ORGCHART_CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
const results = [], servers = [], runtimeErrors = [];
const check = (name, detail = null) => { results.push({ name, status: 'PASS', detail }); console.log(`browser: ${name}: PASS`); };
try {
for (const [name, directory] of Object.entries(projects)) {
const vite = await import(pathToFileURL(createRequire(path.join(directory, 'package.json')).resolve('vite')).href);
const server = await vite.preview({ root: directory, preview: { host: '127.0.0.1', port: 0 }, logLevel: 'silent' });
servers.push(server);
const url = server.resolvedUrls.local[0];
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.on('pageerror', error => runtimeErrors.push({ project: name, message: error.message }));
page.on('console', message => { if (message.type() === 'error') runtimeErrors.push({ project: name, message: message.text() }); });
page.on('response', response => { if (response.status() >= 400) runtimeErrors.push({ project: name, url: response.url(), status: response.status() }); });
async function ready(query) {
await page.goto(url + query);
await page.waitForFunction(() => document.documentElement.dataset.chartState === 'ready');
await page.locator('.hm-orgchart-employee').first().waitFor();
assert.equal(await page.locator('.hm-orgchart-host').count(), 1);
assert.equal(await page.evaluate(() => JSON.stringify(window.verification.data) === window.verification.dataBefore), true);
}
const card = () => page.locator('.hm-orgchart-employee').first();
const coreDialog = () => page.locator('dialog.hm-orgchart-employee-dialog');
const hmDialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]');
async function corePopup(label) {
await card().focus(); await page.keyboard.press('Enter');
await coreDialog().waitFor({ state: 'visible' });
assert.equal(await coreDialog().evaluate(e => e.open), true);
assert.equal(await hmDialog().count(), 0);
await page.keyboard.press('Escape');
await coreDialog().waitFor({ state: 'hidden' });
assert.equal(await card().evaluate(e => e.getRootNode().activeElement === e), true);
await card().click(); await coreDialog().waitFor({ state: 'visible' });
await page.getByRole('button', { name: '직원 정보 닫기', exact: true }).click();
await coreDialog().waitFor({ state: 'hidden' });
check(label);
}
for (const layout of ['auto', 'fixed']) {
await ready(`?layout=${layout}`);
await corePopup(`${name} ${layout}: render, Core popup, keyboard, close, focus`);
assert.deepEqual(await page.evaluate(() => window.verification.errors), []);
await page.screenshot({ path: path.join(evidence, `${name}-${layout}.png`) });
}
if (name !== 'A') {
// Presence of a custom adapter or the HM entry never automatically opts in.
await ready('?layout=auto&adapter=supplied');
await corePopup(`${name}: supplied adapter with default mode retains Core`);
await page.evaluate(() => window.setPopup('adapter'));
await card().click();
await page.locator('[data-component="CustomPopup"]').waitFor({ state: 'visible' });
assert.equal(await coreDialog().evaluate(e => e.open), false);
await page.getByRole('button', { name: 'Close custom' }).click();
await page.locator('[data-component="CustomPopup"]').waitFor({ state: 'detached' });
check(`${name}: custom adapter without Core/data changes`);
await page.evaluate(() => window.setCallback(true));
await card().click();
await page.waitForFunction(() => !!window.clicked);
assert.equal(await page.locator('[data-component="CustomPopup"]').count(), 0);
check(`${name}: onEmployeeClick takes precedence`);
}
if (name === 'B') {
await ready('?layout=auto&popup=hm-dialog');
await corePopup('B: missing HM adapter safely retains Core popup');
assert.ok((await page.evaluate(() => window.verification.errors)).every(e => e.includes('popup adapter is missing')));
}
if (name === 'C') {
await ready('?layout=auto&entry=base');
await corePopup('C: UI installed, base entry still uses Core');
await ready('?layout=auto&popup=hm-dialog');
await card().focus(); await page.keyboard.press('Enter');
await hmDialog().waitFor({ state: 'visible' });
assert.equal(await coreDialog().evaluate(e => e.open), false);
const employeeName = await card().locator('.hm-orgchart-person-name').textContent();
assert.ok((await hmDialog().textContent()).includes(employeeName.trim()));
await page.screenshot({ path: path.join(evidence, 'C-hm-dialog.png') });
await page.keyboard.press('Escape'); await hmDialog().waitFor({ state: 'hidden' });
assert.equal(await card().evaluate(e => e.getRootNode().activeElement === e), true);
await card().click(); await hmDialog().waitFor({ state: 'visible' });
await hmDialog().getByRole('button', { name: '닫기', exact: true }).click(); await hmDialog().waitFor({ state: 'hidden' });
check('C: HM Dialog employee data, Enter, Escape, close, focus restoration');
// Switch ownership without rebuilding Core, with an open HM popup.
await card().click(); await hmDialog().waitFor({ state: 'visible' });
await page.evaluate(() => { window.savedHost = document.querySelector('.hm-orgchart-host'); window.setPopup('core'); });
await hmDialog().waitFor({ state: 'hidden' });
assert.equal(await page.evaluate(() => window.savedHost === document.querySelector('.hm-orgchart-host')), true);
await corePopup('C: HM to Core switch retains same Core instance');
assert.deepEqual(await page.evaluate(() => window.verification.errors), []);
}
await page.close();
}
assert.deepEqual(runtimeErrors, []);
check('No browser exceptions, console errors or HTTP failures', runtimeErrors);
await writeFile(path.join(evidence, 'browser.json'), JSON.stringify({ status: 'PASS', browser: browser.version(), results, runtimeErrors }, null, 2));
} finally {
await browser.close();
await Promise.all(servers.map(server => new Promise(resolve => server.httpServer.close(resolve))));
}
console.log(`PASS: ${results.length} targeted browser checks; artifacts: ${evidence}`);