orca/src/main/daemon/daemon-host-relocation.test.ts

271 lines
11 KiB
TypeScript

import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync
} from 'node:fs'
import os from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Mutable Electron app stub, hoisted so the vi.mock factory closes over it.
const { electronApp } = vi.hoisted(() => ({
electronApp: {
isPackaged: true,
userDataPath: '',
version: '9.9.9',
getPath: (): string => electronApp.userDataPath,
getVersion: (): string => electronApp.version
}
}))
vi.mock('electron', () => ({ app: electronApp }))
import {
buildDaemonHostManifest,
collectPinnedDaemonVersions,
getRelocatedDaemonHost,
materializeRelocatedDaemonHost,
pruneOldDaemonHosts
} from './daemon-host-relocation'
let tempDir: string
let installDir: string
let userDataDir: string
let localAppDataDir: string
const originalPlatform = process.platform
const originalExecPath = process.execPath
const originalResourcesPath = process.resourcesPath
const originalLocalAppData = process.env.LOCALAPPDATA
function setProcessProp(key: string, value: unknown): void {
Object.defineProperty(process, key, { value, configurable: true, writable: true })
}
// Build a win-unpacked fixture: exe + blobs + DLLs at root, daemon bundle and
// node-pty under resources, mirroring the packaged layout the copy expects.
function buildInstallFixture(root: string): void {
mkdirSync(root, { recursive: true })
writeFileSync(join(root, 'Orca.exe'), 'exe-bytes')
for (const name of ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']) {
writeFileSync(join(root, name), name)
}
writeFileSync(join(root, 'ffmpeg.dll'), 'dll')
writeFileSync(join(root, 'libEGL.dll'), 'dll')
const mainDir = join(root, 'resources', 'app.asar.unpacked', 'out', 'main')
mkdirSync(join(mainDir, 'chunks'), { recursive: true })
writeFileSync(join(mainDir, 'daemon-entry.js'), 'entry')
writeFileSync(join(mainDir, 'chunks', 'a.js'), 'chunk')
writeFileSync(join(root, 'resources', 'app.asar.unpacked', 'out', 'package.json'), '{}')
const nativeDir = join(root, 'resources', 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(nativeDir, { recursive: true })
writeFileSync(join(nativeDir, 'conpty.node'), 'native')
writeFileSync(join(nativeDir, 'conpty.pdb'), 'debug-symbols')
mkdirSync(join(nativeDir, 'conpty'), { recursive: true })
writeFileSync(join(nativeDir, 'conpty', 'conpty.dll'), 'conpty-dll')
// Both win32 prebuilds exist in the packaged tree (build-time prune keeps the
// `win32-` prefix); the copy filter keeps the host arch's and drops the other.
const prebuildsRoot = join(root, 'resources', 'node_modules', 'node-pty', 'prebuilds')
for (const arch of ['win32-x64', 'win32-arm64']) {
mkdirSync(join(prebuildsRoot, arch), { recursive: true })
writeFileSync(join(prebuildsRoot, arch, 'pty.node'), `${arch}-prebuild`)
}
}
// The win32 prebuild dir the running host arch loads vs. the one that is pruned.
const HOST_PREBUILD = `win32-${process.arch}`
const OTHER_PREBUILD = HOST_PREBUILD === 'win32-arm64' ? 'win32-x64' : 'win32-arm64'
beforeEach(() => {
tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-relocation-'))
installDir = join(tempDir, 'app')
userDataDir = join(tempDir, 'userData')
mkdirSync(userDataDir, { recursive: true })
localAppDataDir = join(tempDir, 'localAppData')
mkdirSync(localAppDataDir, { recursive: true })
process.env.LOCALAPPDATA = localAppDataDir
buildInstallFixture(installDir)
electronApp.isPackaged = true
electronApp.userDataPath = userDataDir
electronApp.version = '9.9.9'
setProcessProp('platform', 'win32')
setProcessProp('execPath', join(installDir, 'Orca.exe'))
setProcessProp('resourcesPath', join(installDir, 'resources'))
})
afterEach(() => {
setProcessProp('platform', originalPlatform)
setProcessProp('execPath', originalExecPath)
setProcessProp('resourcesPath', originalResourcesPath)
if (originalLocalAppData === undefined) {
delete process.env.LOCALAPPDATA
} else {
process.env.LOCALAPPDATA = originalLocalAppData
}
try {
rmSync(tempDir, { recursive: true, force: true })
} catch {
// Best-effort
}
})
describe('buildDaemonHostManifest', () => {
it('mirrors the win-unpacked layout: exe + data blobs + resources tree, no GPU DLLs', () => {
const appDir = 'C:\\app'
const ops = buildDaemonHostManifest({
appDir,
execPath: 'C:\\app\\Orca.exe',
resourcesPath: 'C:\\app\\resources',
entrySourcePath: 'C:\\app\\resources\\app.asar.unpacked\\out\\main\\daemon-entry.js',
entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js'
})
const byDest = new Map(ops.map((op) => [op.destRel, op]))
// The host exe is renamed to a distinct image name (NOT the source basename)
// so the NSIS updater's name-based `taskkill /IM Orca.exe` can't kill it.
expect(byDest.get('orca-terminal-daemon.exe')?.kind).toBe('file')
expect(byDest.has('Orca.exe')).toBe(false)
const exeOp = ops.find((op) => op.sourcePath === 'C:\\app\\Orca.exe')
expect(exeOp?.destRel).not.toBe('Orca.exe')
// V8/ICU data blobs are read by the Electron bootstrap and kept.
expect(byDest.has('icudtl.dat')).toBe(true)
// GPU/graphics DLLs are never loaded by the windowless host, so not copied.
expect(byDest.has('ffmpeg.dll')).toBe(false)
expect(byDest.has('libEGL.dll')).toBe(false)
// Daemon bundle + node-pty mirrored at their real resources-relative paths.
expect(byDest.get('resources/app.asar.unpacked/out/main/daemon-entry.js')?.kind).toBe('file')
expect(byDest.get('resources/app.asar.unpacked/out/main/chunks')?.kind).toBe('dir')
// node-pty is copied with a filter dropping .pdb + non-host-arch prebuilds.
const nodePtyOp = byDest.get('resources/node_modules/node-pty')
expect(nodePtyOp?.kind).toBe('dir')
expect(nodePtyOp?.filter?.('node-pty/build/Release/conpty.node')).toBe(true)
expect(nodePtyOp?.filter?.('node-pty/build/Release/conpty.pdb')).toBe(false)
expect(nodePtyOp?.filter?.(`node-pty/prebuilds/${HOST_PREBUILD}/pty.node`)).toBe(true)
expect(nodePtyOp?.filter?.(`node-pty/prebuilds/${OTHER_PREBUILD}/pty.node`)).toBe(false)
})
})
describe('materializeRelocatedDaemonHost', () => {
it('copies the tree, writes the marker, and returns mirrored fork paths', () => {
const result = materializeRelocatedDaemonHost()
expect(result).not.toBeNull()
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe'))
expect(result?.entryPath).toBe(
join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js')
)
expect(existsSync(result!.execPath)).toBe(true)
expect(existsSync(result!.entryPath)).toBe(true)
// node-pty native + conpty runtime copied at the require-resolvable path.
expect(
existsSync(
join(dest, 'resources', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.node')
)
).toBe(true)
expect(
existsSync(join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'chunks', 'a.js'))
).toBe(true)
// Trim: GPU DLLs, .pdb debug symbols, and non-host-arch prebuilds excluded;
// the host arch's prebuild is retained so node-pty resolves its native addon.
expect(existsSync(join(dest, 'ffmpeg.dll'))).toBe(false)
expect(existsSync(join(dest, 'libEGL.dll'))).toBe(false)
expect(
existsSync(
join(dest, 'resources', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.pdb')
)
).toBe(false)
const prebuildsDest = join(dest, 'resources', 'node_modules', 'node-pty', 'prebuilds')
expect(existsSync(join(prebuildsDest, HOST_PREBUILD, 'pty.node'))).toBe(true)
expect(existsSync(join(prebuildsDest, OTHER_PREBUILD, 'pty.node'))).toBe(false)
// Marker records the version + entry rel path, written into the published dir.
const marker = JSON.parse(readFileSync(join(dest, '.materialized.json'), 'utf8'))
expect(marker.version).toBe('9.9.9')
expect(marker.entryRelPath).toBe('resources/app.asar.unpacked/out/main/daemon-entry.js')
})
it('is idempotent: a valid marker short-circuits without recopying', () => {
materializeRelocatedDaemonHost()
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
// A recopy would rm the dest; a sentinel inside it must survive the 2nd call.
const sentinel = join(dest, 'sentinel.txt')
writeFileSync(sentinel, 'keep')
const result = materializeRelocatedDaemonHost()
expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe'))
expect(existsSync(sentinel)).toBe(true)
})
it('fails open on a missing required input, leaving no dest or staging dir', () => {
rmSync(join(installDir, 'resources', 'node_modules', 'node-pty'), {
recursive: true,
force: true
})
const result = materializeRelocatedDaemonHost()
expect(result).toBeNull()
const hostRoot = join(localAppDataDir, 'Orca', 'daemon-host')
// Neither the published dest nor any leftover staging dir remains.
const remaining = existsSync(hostRoot) ? readdirSync(hostRoot) : []
expect(remaining).toEqual([])
})
it('returns null off win32', () => {
setProcessProp('platform', 'darwin')
expect(materializeRelocatedDaemonHost()).toBeNull()
expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false)
})
})
describe('getRelocatedDaemonHost', () => {
it('returns null when the marker version does not match the current version', () => {
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
mkdirSync(dirname(join(dest, 'x')), { recursive: true })
writeFileSync(join(dest, 'Orca.exe'), 'exe')
mkdirSync(join(dest, 'resources', 'app.asar.unpacked', 'out', 'main'), { recursive: true })
writeFileSync(
join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js'),
'e'
)
writeFileSync(
join(dest, '.materialized.json'),
JSON.stringify({
version: '8.8.8',
completedAt: '',
entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js'
})
)
expect(getRelocatedDaemonHost()).toBeNull()
})
})
describe('pruneOldDaemonHosts', () => {
it('removes unpinned non-current version dirs, keeping current and pinned', () => {
const root = join(localAppDataDir, 'Orca', 'daemon-host')
for (const v of ['9.9.9', '1.0.0', '2.0.0']) {
mkdirSync(join(root, v), { recursive: true })
}
pruneOldDaemonHosts(new Set(['2.0.0']))
expect(existsSync(join(root, '9.9.9'))).toBe(true)
expect(existsSync(join(root, '2.0.0'))).toBe(true)
expect(existsSync(join(root, '1.0.0'))).toBe(false)
})
})
describe('collectPinnedDaemonVersions', () => {
it('pins the app version of a live daemon pid file and skips dead ones', () => {
const runtimeDir = join(userDataDir, 'daemon')
mkdirSync(runtimeDir, { recursive: true })
writeFileSync(
join(runtimeDir, 'daemon-v4.pid'),
JSON.stringify({ pid: process.pid, startedAtMs: null, appVersion: '7.0.0' })
)
writeFileSync(
join(runtimeDir, 'daemon-v3.pid'),
JSON.stringify({ pid: 2147483646, startedAtMs: null, appVersion: '6.0.0' })
)
const pinned = collectPinnedDaemonVersions(runtimeDir)
expect(pinned.has('7.0.0')).toBe(true)
expect(pinned.has('6.0.0')).toBe(false)
})
})