226 lines
15 KiB
JavaScript
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}`);
|