159 lines
5.5 KiB
TypeScript
159 lines
5.5 KiB
TypeScript
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { homedir, tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { build } from 'esbuild'
|
|
import type { TestInfo } from '@playwright/test'
|
|
|
|
const TEMP_PREFIX = 'orca-9749-dg-'
|
|
|
|
type DiagnosticGeneration = {
|
|
label: string
|
|
protocolVersion: number
|
|
identity: { pid: number }
|
|
socketPath: string
|
|
logEvents(): Record<string, unknown>[]
|
|
startupLog(): string
|
|
}
|
|
|
|
export type DaemonGenerationRuntime = {
|
|
rootDir: string
|
|
userDataDir: string
|
|
daemonDir: string
|
|
entryPath: string
|
|
reconnectClientEntryPath: string
|
|
legacyCloseClientEntryPath: string
|
|
canaryPath: string
|
|
electronPath: string
|
|
retainDiagnostics(generations: readonly DiagnosticGeneration[]): void
|
|
remove(): void
|
|
}
|
|
|
|
function normalizeForContainment(candidate: string): string {
|
|
const normalized = path.resolve(candidate)
|
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
|
}
|
|
|
|
function isEqualToOrInside(candidate: string, parent: string): boolean {
|
|
const normalizedCandidate = normalizeForContainment(candidate)
|
|
const normalizedParent = normalizeForContainment(parent)
|
|
return (
|
|
normalizedCandidate === normalizedParent ||
|
|
normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`)
|
|
)
|
|
}
|
|
|
|
function knownOrcaUserDataDirs(): string[] {
|
|
if (process.platform === 'darwin') {
|
|
const appSupport = path.join(homedir(), 'Library', 'Application Support')
|
|
return [path.join(appSupport, 'orca'), path.join(appSupport, 'orca-dev')]
|
|
}
|
|
if (process.platform === 'win32') {
|
|
const roaming = process.env.APPDATA ?? path.join(homedir(), 'AppData', 'Roaming')
|
|
return [path.join(roaming, 'orca'), path.join(roaming, 'orca-dev')]
|
|
}
|
|
const config = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), '.config')
|
|
return [path.join(config, 'orca'), path.join(config, 'orca-dev')]
|
|
}
|
|
|
|
function assertDisposableRoot(rootDir: string): void {
|
|
const tempRoot = process.platform === 'darwin' ? path.join(path.sep, 'tmp') : tmpdir()
|
|
if (!path.basename(rootDir).startsWith(TEMP_PREFIX)) {
|
|
throw new Error('Refusing daemon-generation cleanup without its fixture prefix')
|
|
}
|
|
if (!isEqualToOrInside(rootDir, tempRoot)) {
|
|
throw new Error('Daemon-generation fixture escaped the OS temporary directory')
|
|
}
|
|
for (const userDataDir of knownOrcaUserDataDirs()) {
|
|
if (isEqualToOrInside(rootDir, userDataDir)) {
|
|
throw new Error('Refusing daemon-generation fixture inside real Orca user data')
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveElectronExecutable(repoRoot: string): string {
|
|
const relativePath = readFileSync(
|
|
path.join(repoRoot, 'node_modules', 'electron', 'path.txt'),
|
|
'utf8'
|
|
).trim()
|
|
const executable = path.join(repoRoot, 'node_modules', 'electron', 'dist', relativePath)
|
|
if (!existsSync(executable)) {
|
|
throw new Error(`Local Electron executable is missing: ${executable}`)
|
|
}
|
|
return executable
|
|
}
|
|
|
|
async function buildFixtureEntry(entryPoint: string, outfile: string): Promise<void> {
|
|
await build({
|
|
entryPoints: [entryPoint],
|
|
outfile,
|
|
bundle: true,
|
|
platform: 'node',
|
|
format: 'cjs',
|
|
target: 'node20',
|
|
packages: 'external',
|
|
external: ['node-pty'],
|
|
logLevel: 'silent'
|
|
})
|
|
}
|
|
|
|
export async function createDaemonGenerationRuntime(
|
|
testInfo: TestInfo
|
|
): Promise<DaemonGenerationRuntime> {
|
|
const tempRoot = process.platform === 'darwin' ? path.join(path.sep, 'tmp') : tmpdir()
|
|
const rootDir = mkdtempSync(path.join(tempRoot, TEMP_PREFIX))
|
|
assertDisposableRoot(rootDir)
|
|
const userDataDir = path.join(rootDir, 'user-data')
|
|
const daemonDir = path.join(userDataDir, 'daemon')
|
|
mkdirSync(daemonDir, { recursive: true })
|
|
const entryPath = path.join(rootDir, 'daemon-generation-entry.cjs')
|
|
const reconnectClientEntryPath = path.join(rootDir, 'daemon-generation-reconnect-client.cjs')
|
|
const legacyCloseClientEntryPath = path.join(rootDir, 'daemon-generation-legacy-close-client.cjs')
|
|
const repoRoot = process.cwd()
|
|
await buildFixtureEntry(
|
|
path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-entry.ts'),
|
|
entryPath
|
|
)
|
|
await buildFixtureEntry(
|
|
path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-reconnect-client.ts'),
|
|
reconnectClientEntryPath
|
|
)
|
|
await buildFixtureEntry(
|
|
path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-legacy-close-client.ts'),
|
|
legacyCloseClientEntryPath
|
|
)
|
|
return {
|
|
rootDir,
|
|
userDataDir,
|
|
daemonDir,
|
|
entryPath,
|
|
reconnectClientEntryPath,
|
|
legacyCloseClientEntryPath,
|
|
canaryPath: path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-canary.cjs'),
|
|
electronPath: resolveElectronExecutable(repoRoot),
|
|
retainDiagnostics: (generations) => {
|
|
mkdirSync(testInfo.outputDir, { recursive: true })
|
|
writeFileSync(
|
|
testInfo.outputPath('daemon-generation-safety-diagnostics.json'),
|
|
`${JSON.stringify(
|
|
generations.map((generation) => ({
|
|
label: generation.label,
|
|
protocolVersion: generation.protocolVersion,
|
|
pid: generation.identity.pid,
|
|
socketPath: generation.socketPath,
|
|
logEvents: generation.logEvents(),
|
|
startupLog: generation.startupLog()
|
|
})),
|
|
null,
|
|
2
|
|
)}\n`
|
|
)
|
|
},
|
|
remove: () => {
|
|
assertDisposableRoot(rootDir)
|
|
// Why: Windows can release ConPTY/log handles just after exact-PID
|
|
// liveness reaches zero; keep fixture cleanup bounded but non-flaky.
|
|
rmSync(rootDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
|
|
}
|
|
}
|
|
}
|