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'), 'OrgChart package verification
'); 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 ?

{selection.employee.name}

: null; } function App() { const [mode, setMode] = useState>((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 { Object.assign(window, { clicked: { employee, organization: context.organization } }); } : undefined} onStateChange={onStateChange} onLifecycleError={onLifecycleError} />; } createRoot(document.getElementById('app')!).render(); `); 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}`);