import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' import { createRequire } from 'node:module' import { pathToFileURL } from 'node:url' import { runProcessSync } from '@orca/process-host' import { rolldown, watch } from 'rolldown' import type { Plugin, RolldownWatcher } from 'rolldown' import { afterEach, describe, expect, it, vi } from 'vitest' import { createProcessHostDevRebuildPlugin } from '../build-plugins/process-host-dev-rebuild' const realPackageDir = resolve('src/packages/process-host') const temporaryRoots: string[] = [] const watchers: RolldownWatcher[] = [] afterEach(async () => { await Promise.all(watchers.splice(0).map((watcher) => watcher.close())) for (const root of temporaryRoots.splice(0)) { rmSync(root, { recursive: true, force: true }) } }) function fixture(): { root: string; packageDir: string; main: string } { const root = mkdtempSync(join(tmpdir(), 'orca-process-host-dev-')) temporaryRoots.push(root) const packageDir = join(root, 'src', 'packages', 'process-host') const sourceDir = join(packageDir, 'src') mkdirSync(sourceDir, { recursive: true }) mkdirSync(join(packageDir, 'scripts')) // Runs the real package build against this fixture package. writeFileSync( join(packageDir, 'scripts', 'build-dist.mjs'), `import { appendFileSync } from 'node:fs'\nappendFileSync(${JSON.stringify(join(packageDir, 'builds.txt'))}, 'build\\n')\nconst { buildPackageDist } = await import(${JSON.stringify(pathToFileURL(resolve(realPackageDir, 'scripts', 'build-dist.mjs')).href)})\nawait buildPackageDist(${JSON.stringify(packageDir)})\n` ) mkdirSync(join(root, 'node_modules', '@orca'), { recursive: true }) const packageRequire = createRequire(join(realPackageDir, 'package.json')) for (const tool of ['typescript', 'tsx']) { symlinkSync( dirname(packageRequire.resolve(`${tool}/package.json`)), join(root, 'node_modules', tool), 'junction' ) } symlinkSync(packageDir, join(root, 'node_modules', '@orca', 'process-host'), 'junction') writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - src/packages/*\n') writeFileSync( join(packageDir, 'package.json'), JSON.stringify({ name: '@orca/process-host', type: 'commonjs', exports: { '.': { 'orca-source': './src/entry.ts', default: './dist/entry.js' } } }) ) writeFileSync( join(root, 'tsconfig.base.json'), JSON.stringify({ compilerOptions: { strict: true } }) ) writeFileSync( join(packageDir, 'tsconfig.json'), JSON.stringify({ extends: '../../../tsconfig.base.json', compilerOptions: { target: 'ES2022', lib: ['ES2022'], module: 'Node16', moduleResolution: 'Node16', rootDir: 'src', outDir: 'dist', noEmitOnError: true, types: [] }, include: ['src/**/*.ts'] }) ) writeFileSync(join(sourceDir, 'entry.ts'), "export { version } from './internal'\n") writeFileSync(join(sourceDir, 'internal.ts'), '// preserved comment\nexport const version = 1\n') const main = join(root, 'main.cjs') writeFileSync(main, "module.exports = require('@orca/process-host')\n") return { root, packageDir, main } } function nextEvent(watcher: RolldownWatcher, code: 'END' | 'ERROR'): Promise { return new Promise((resolveEvent, reject) => { const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${code}`)), 15_000) watcher.on('event', function listener(event) { if (event.code === code) { clearTimeout(timeout) watcher.off('event', listener) resolveEvent() } else if (event.code === 'ERROR' && code !== 'ERROR') { clearTimeout(timeout) watcher.off('event', listener) reject(event.error) } }) }) } function closeSuccessfulBundles(watcher: RolldownWatcher): void { // Vite closes successful watch outputs, which invokes electron-vite's reload hook. watcher.on('event', async (event) => { if (event.code === 'BUNDLE_END') { await event.result.close() } }) } function readEmittedVersion(root: string): number { const result = runProcessSync({ program: process.execPath, args: ['-e', `console.log(require(${JSON.stringify(join(root, 'out', 'main.cjs'))}).version)`], cwd: root, env: { ...process.env, NODE_OPTIONS: '', NODE_PATH: '' } }) if (result.code !== 0) { throw new Error(result.stderr || 'The emitted main entry did not load') } return Number(result.stdout.trim()) } describe('External process-host development rebuilds', () => { it('skips package compilation when only application code changes', async () => { const { root, packageDir, main } = fixture() const watcher = watch({ input: main, external: ['@orca/process-host'], plugins: [createProcessHostDevRebuildPlugin(root)], output: { file: join(root, 'out', 'main.cjs'), format: 'cjs' } }) watchers.push(watcher) closeSuccessfulBundles(watcher) await nextEvent(watcher, 'END') const builds = join(packageDir, 'builds.txt') expect(readFileSync(builds, 'utf8')).toBe('build\n') const rebuilt = nextEvent(watcher, 'END') writeFileSync(main, "module.exports = { ...require('@orca/process-host'), changed: true }\n") await rebuilt expect(readEmittedVersion(root)).toBe(1) expect(readFileSync(join(root, 'out', 'main.cjs'), 'utf8')).toContain('changed') expect(readFileSync(builds, 'utf8')).toBe('build\n') }) it('emits private source edits and new imports before the Electron reload hook', async () => { const { root, packageDir, main } = fixture() const loadedVersions: number[] = [] const reloadHook: Plugin = { name: 'fake-electron-reload', closeBundle() { loadedVersions.push(readEmittedVersion(root)) } } const watcher = watch({ input: main, external: ['@orca/process-host'], plugins: [createProcessHostDevRebuildPlugin(root), reloadHook], output: { file: join(root, 'out', 'main.cjs'), format: 'cjs' } }) watchers.push(watcher) closeSuccessfulBundles(watcher) await nextEvent(watcher, 'END') expect(loadedVersions).toEqual([1]) expect(readFileSync(join(root, 'out', 'main.cjs'), 'utf8')).toContain('@orca/process-host') writeFileSync(join(packageDir, 'src', 'internal.ts'), 'export const version = 2\n') await expect.poll(() => loadedVersions.at(-1), { timeout: 15_000 }).toBe(2) writeFileSync(join(packageDir, 'src', 'new-feature.ts'), 'export const added = 3\n') writeFileSync( join(packageDir, 'src', 'internal.ts'), "import { added } from './new-feature'\nexport const version = added\n" ) await expect.poll(() => loadedVersions.at(-1), { timeout: 15_000 }).toBe(3) await expect .poll(() => existsSync(join(packageDir, 'dist', 'new-feature.js')), { timeout: 15_000 }) .toBe(true) expect(readFileSync(join(packageDir, 'dist', 'new-feature.js'), 'utf8')).toContain('added = 3') }) it('rebuilds when an inherited compiler configuration changes', async () => { const { root, packageDir, main } = fixture() const watcher = watch({ input: main, external: ['@orca/process-host'], plugins: [createProcessHostDevRebuildPlugin(root)], output: { file: join(root, 'out', 'main.cjs'), format: 'cjs' } }) watchers.push(watcher) closeSuccessfulBundles(watcher) await nextEvent(watcher, 'END') const emittedSource = join(packageDir, 'dist', 'internal.js') expect(readFileSync(emittedSource, 'utf8')).toContain('preserved comment') writeFileSync( join(root, 'tsconfig.base.json'), JSON.stringify({ compilerOptions: { strict: true, removeComments: true } }) ) await expect .poll(() => readFileSync(emittedSource, 'utf8'), { timeout: 15_000 }) .not.toContain('preserved comment') }) it('reports a failed compilation, preserves prior output, and recovers after a fix', async () => { const { root, packageDir, main } = fixture() const loadedVersions: number[] = [] const watcher = watch({ input: main, external: ['@orca/process-host'], plugins: [ createProcessHostDevRebuildPlugin(root), { name: 'fake-electron-reload', closeBundle() { loadedVersions.push(readEmittedVersion(root)) } } ], output: { file: join(root, 'out', 'main.cjs'), format: 'cjs' } }) watchers.push(watcher) closeSuccessfulBundles(watcher) await nextEvent(watcher, 'END') const failed = nextEvent(watcher, 'ERROR') writeFileSync(join(packageDir, 'src', 'internal.ts'), "export const version: number = 'bad'\n") await failed expect(loadedVersions.at(-1)).toBe(1) expect(readEmittedVersion(root)).toBe(1) writeFileSync(join(packageDir, 'src', 'internal.ts'), 'export const version = 4\n') await expect.poll(() => loadedVersions.at(-1), { timeout: 15_000 }).toBe(4) }) it('recovers when a missing import is created in a new source directory', async () => { const { root, packageDir, main } = fixture() const loadedVersions: number[] = [] const watchSignals = new Set() const rebuildPlugin = createProcessHostDevRebuildPlugin(root) const buildStart = rebuildPlugin.buildStart if (typeof buildStart !== 'function') { throw new Error('The process-host rebuild plugin must have a buildStart hook') } rebuildPlugin.buildStart = function (...args) { const addWatchFile = this.addWatchFile.bind(this) const registration = vi.spyOn(this, 'addWatchFile').mockImplementation((file) => { addWatchFile(file) if (basename(file) === 'source-directory-change') { watchSignals.add(file) } }) try { return buildStart.apply(this, args) } finally { registration.mockRestore() } } const watcher = watch({ input: main, external: ['@orca/process-host'], plugins: [ rebuildPlugin, { name: 'fake-electron-reload', closeBundle() { loadedVersions.push(readEmittedVersion(root)) } } ], output: { file: join(root, 'out', 'main.cjs'), format: 'cjs' } }) watchers.push(watcher) closeSuccessfulBundles(watcher) await nextEvent(watcher, 'END') const failed = nextEvent(watcher, 'ERROR') writeFileSync( join(packageDir, 'src', 'internal.ts'), "export { version } from './features/new-feature'\n" ) await failed expect(loadedVersions.at(-1)).toBe(1) expect(readEmittedVersion(root)).toBe(1) mkdirSync(join(packageDir, 'src', 'features')) writeFileSync( join(packageDir, 'src', 'features', 'new-feature.ts'), 'export const version = 5\n' ) await expect.poll(() => loadedVersions.at(-1), { timeout: 15_000 }).toBe(5) expect(existsSync(join(packageDir, 'dist', 'features', 'new-feature.js'))).toBe(true) expect(watchSignals.size).toBe(1) for (const signal of watchSignals) { expect(existsSync(signal)).toBe(true) } const failedAgain = nextEvent(watcher, 'ERROR') writeFileSync( join(packageDir, 'src', 'internal.ts'), "export { version } from './features/still-missing'\n" ) await failedAgain expect(readEmittedVersion(root)).toBe(5) await watcher.close() watchers.splice(watchers.indexOf(watcher), 1) for (const signal of watchSignals) { expect(existsSync(dirname(signal))).toBe(false) } }) it('leaves non-watch release compilation to the build:packages prerequisite', async () => { const bundle = await rolldown({ input: resolve('src/packages/process-host/src/process-spec.ts'), plugins: [createProcessHostDevRebuildPlugin('/unavailable-development-root')] }) try { await bundle.generate({ format: 'cjs' }) } finally { await bundle.close() } }) })