Files
orca/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts
T
Brennan Benson 2ee43bfc0d fix(agent-hooks): refresh existing Orca launchers when agent CLIs are unavailable (#13378)
* fix(agent-hooks): refresh existing shared hook scripts when the CLI is no longer detected

A CLI that falls off PATH (moved npm prefix, relocated shim) keeps its user-wide
config invoking Orca's launcher script under ~/.orca/agent-hooks, but the
presence gate skips install() with no removal — freezing the script at whatever
Orca generated last. Anyone in that state kept the pre-#11568 more.com-leaking
.cmd forever, because no launcher script is ever deleted and Windows startup
deliberately skips shell PATH hydration.

Reconcile before gating: every existing shared launcher/statusline script is
rewritten to the current template on each install pass. Creating scripts stays
behind the presence gate — an existing file is proof of a prior install; a
missing one means the gate did its job. Amp and Hermes are deliberately absent:
they write provider-native plugin code with its own install lifecycle, not
shared launchers.

- refreshManagedScriptIfPresent() in installer-utils (no-op unless the file exists)
- refreshManagedScripts() on the 11 launcher-writing services (openclaude via
  the shared Claude class)
- reconcile pass in installManagedAgentHooks before presence detection,
  filtered by the agents option, best-effort per agent
- coverage gate: a launcher written to ~/.orca/agent-hooks without a matching
  refresher entry fails the suite, in both directions

* perf(agent-hooks): refresh launchers off the main thread

* test(agent-hooks): keep refresh mode assertion POSIX-only
2026-08-10 16:34:15 -07:00

102 lines
3.3 KiB
TypeScript

import type * as NodeFsModule from 'node:fs'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import { tmpdir } from 'node:os'
import type * as NodeOsModule from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
home: '',
syncCalls: [] as { name: string; target: string }[],
blockAsyncReads: false
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsModule>()
const wrapped: Record<string, unknown> = { ...actual }
for (const [name, value] of Object.entries(actual)) {
if (!name.endsWith('Sync') || typeof value !== 'function') {
continue
}
const original = value as ((...args: unknown[]) => unknown) & Record<string, unknown>
const recorder = (...args: unknown[]): unknown => {
state.syncCalls.push({ name, target: typeof args[0] === 'string' ? args[0] : '' })
return original(...args)
}
Object.assign(recorder, original)
wrapped[name] = recorder
}
return { ...wrapped, default: wrapped }
})
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromisesModule>()
return {
...actual,
default: actual,
readFile: (...args: Parameters<typeof actual.readFile>) =>
state.blockAsyncReads && String(args[0]).startsWith(state.home)
? new Promise<never>(() => {})
: actual.readFile(...args)
}
})
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof NodeOsModule>()
return { ...actual, default: actual, homedir: () => state.home }
})
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' } }))
import { MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS } from './managed-agent-hook-registry'
function syncCallsUnderHome(): string[] {
return state.syncCalls
.filter((call) => call.target.startsWith(state.home))
.map((call) => `${call.name}(${call.target})`)
}
describe('managed hook script refresh stays off the main thread', () => {
beforeEach(async () => {
state.home = await mkdtemp(join(tmpdir(), 'orca-hook-refresh-main-thread-'))
state.syncCalls = []
state.blockAsyncReads = false
})
afterEach(async () => {
state.blockAsyncReads = false
await rm(state.home, { recursive: true, force: true })
})
it('uses no synchronous HOME filesystem calls for missing or stale scripts', async () => {
const hooksDir = join(state.home, '.orca', 'agent-hooks')
const claudeScript = join(
hooksDir,
process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
)
await mkdir(hooksDir, { recursive: true })
await writeFile(claudeScript, 'stale', 'utf-8')
state.syncCalls = []
for (const [, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
await refresh()
}
expect(syncCallsUnderHome()).toEqual([])
})
it('keeps the event loop responsive while a HOME read is stalled', async () => {
state.blockAsyncReads = true
const refresh = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS[0][1]()
let settled = false
void refresh.then(() => {
settled = true
})
await new Promise<void>((resolve) => setTimeout(resolve, 5))
expect(settled).toBe(false)
})
})