mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
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'],
|
|
exclude: ['src/**/*.test.ts', 'src/**/__fixtures__/**']
|
|
})
|
|
)
|
|
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<void> {
|
|
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('ignores excluded files and rebuilds application edits without recompiling the package', async () => {
|
|
const { root, packageDir, main } = fixture()
|
|
const fixtureDir = join(packageDir, 'src', '__fixtures__')
|
|
mkdirSync(fixtureDir)
|
|
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')
|
|
let reloads = 0
|
|
watcher.on('event', (event) => {
|
|
if (event.code === 'END') {
|
|
reloads++
|
|
}
|
|
})
|
|
const testFile = join(packageDir, 'src', 'unrelated.test.ts')
|
|
writeFileSync(testFile, "const invalid: number = 'excluded'\n")
|
|
writeFileSync(join(fixtureDir, 'unrelated.ts'), "const invalid: number = 'excluded'\n")
|
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 500))
|
|
rmSync(testFile)
|
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 500))
|
|
|
|
expect(readFileSync(join(packageDir, 'builds.txt'), 'utf8')).toBe('build\n')
|
|
expect(reloads).toBe(0)
|
|
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(join(packageDir, 'builds.txt'), 'utf8')).toBe('build\n')
|
|
})
|
|
|
|
it('rebuilds when an explicitly imported excluded source is created or edited', async () => {
|
|
const { root, packageDir, main } = fixture()
|
|
const versions: number[] = []
|
|
const watcher = watch({
|
|
input: main,
|
|
external: ['@orca/process-host'],
|
|
plugins: [
|
|
createProcessHostDevRebuildPlugin(root),
|
|
{
|
|
name: 'fake-electron-reload',
|
|
closeBundle() {
|
|
versions.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 './included.test'\n"
|
|
)
|
|
await failed
|
|
|
|
const imported = join(packageDir, 'src', 'included.test.ts')
|
|
writeFileSync(imported, 'export const version = 6\n')
|
|
await expect.poll(() => versions.at(-1), { timeout: 15_000 }).toBe(6)
|
|
writeFileSync(imported, 'export const version = 7\n')
|
|
await expect.poll(() => versions.at(-1), { timeout: 15_000 }).toBe(7)
|
|
})
|
|
|
|
it('recompiles when a nested package manifest is created or removed', async () => {
|
|
const { root, packageDir, main } = fixture()
|
|
writeFileSync(join(packageDir, 'src', 'entry.ts'), 'export const version = 1\n')
|
|
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 output = join(packageDir, 'dist', 'entry.js')
|
|
expect(readFileSync(output, 'utf8')).toContain('exports.version')
|
|
|
|
const manifest = join(packageDir, 'src', 'package.json')
|
|
writeFileSync(manifest, JSON.stringify({ type: 'module' }))
|
|
await expect
|
|
.poll(() => readFileSync(output, 'utf8'), { timeout: 15_000 })
|
|
.toContain('export const')
|
|
rmSync(manifest)
|
|
await expect
|
|
.poll(() => readFileSync(output, 'utf8'), { timeout: 15_000 })
|
|
.toContain('exports.version')
|
|
})
|
|
|
|
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<string>()
|
|
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 = async 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 await 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()
|
|
}
|
|
})
|
|
})
|