orca/src/relay/plugin-overlay.test.ts

343 lines
14 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync
} from 'fs'
import { tmpdir } from 'os'
import { basename, join } from 'path'
import { PluginOverlayManager } from './plugin-overlay'
import { resolvePiSourceAgentDir } from './plugin-overlay-env'
describe('PluginOverlayManager', () => {
let homeDir: string
let manager: PluginOverlayManager
beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), 'plugin-overlay-'))
manager = new PluginOverlayManager({ homeDir })
})
afterEach(() => {
rmSync(homeDir, { recursive: true, force: true })
})
it('reports no source until install runs', () => {
expect(manager.hasOpenCodeSource()).toBe(false)
expect(manager.hasPiSource()).toBe(false)
expect(manager.materializeOpenCode('tab-1:0')).toBeNull()
expect(manager.materializePi('tab-1:0')).toBeNull()
})
it('materializes OpenCode plugin into <overlay>/plugins/<file>', () => {
manager.setSources({ opencodePluginSource: 'export const X = 1' })
const dir = manager.materializeOpenCode('tab-1:0')
expect(dir).not.toBeNull()
const expected = join(dir!, 'plugins', 'orca-opencode-status.js')
expect(existsSync(expected)).toBe(true)
expect(readFileSync(expected, 'utf8')).toBe('export const X = 1')
})
it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => {
const userConfigDir = join(homeDir, 'company-opencode')
mkdirSync(join(userConfigDir, 'plugins'), { recursive: true })
writeFileSync(join(userConfigDir, 'opencode.json'), '{"provider":"custom"}')
writeFileSync(join(userConfigDir, 'plugins', 'user-plugin.js'), 'user plugin')
writeFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'user same-name')
manager.setSources({ opencodePluginSource: 'orca plugin' })
const dir = manager.materializeOpenCode('tab-opencode:0', userConfigDir)
expect(dir).not.toBeNull()
expect(readFileSync(join(dir!, 'opencode.json'), 'utf8')).toBe('{"provider":"custom"}')
expect(readFileSync(join(dir!, 'plugins', 'user-plugin.js'), 'utf8')).toBe('user plugin')
expect(readFileSync(join(dir!, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe(
'orca plugin'
)
expect(readFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe(
'user same-name'
)
})
it('does not override a missing preexisting OpenCode config dir', () => {
manager.setSources({ opencodePluginSource: 'orca plugin' })
expect(manager.materializeOpenCode('tab-missing:0', join(homeDir, 'missing'))).toBeNull()
})
it('materializes Pi extension into <overlay>/extensions/<file>', () => {
manager.setSources({ piExtensionSource: '// pi extension' })
const dir = manager.materializePi('tab-2:0')
expect(dir).not.toBeNull()
const file = join(dir!, 'extensions', 'orca-agent-status.ts')
expect(existsSync(file)).toBe(true)
})
it('uses the kind-specific Pi-compatible extension source when available', () => {
manager.setSources({
piExtensionSource: '// pi extension',
ompExtensionSource: '// omp extension'
})
const piDir = manager.materializePi('tab-kind-pi:0', undefined, 'pi')
const ompDir = manager.materializePi('tab-kind-omp:0', undefined, 'omp')
expect(piDir).not.toBeNull()
expect(ompDir).not.toBeNull()
expect(readFileSync(join(piDir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe(
'// pi extension'
)
expect(readFileSync(join(ompDir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe(
'// omp extension'
)
})
it('mirrors the remote default Pi agent dir before adding Orca status extension', () => {
const piAgentDir = join(homeDir, '.pi', 'agent')
mkdirSync(join(piAgentDir, 'skills', 'my-skill'), { recursive: true })
mkdirSync(join(piAgentDir, 'extensions', 'user-ext'), { recursive: true })
writeFileSync(join(piAgentDir, 'auth.json'), 'secret token')
writeFileSync(join(piAgentDir, 'skills', 'my-skill', 'SKILL.md'), 'critical user skill')
writeFileSync(join(piAgentDir, 'extensions', 'user-ext', 'ext.ts'), 'user extension')
writeFileSync(
join(piAgentDir, 'settings.json'),
JSON.stringify({
defaultProvider: 'amazon-bedrock',
hideThinkingBlock: false,
terminal: {
showImages: false,
clearOnShrink: false
}
})
)
manager.setSources({ piExtensionSource: '// pi extension' })
const dir = manager.materializePi('tab-pi:0')
expect(dir).not.toBeNull()
expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('secret token')
expect(readFileSync(join(dir!, 'skills', 'my-skill', 'SKILL.md'), 'utf8')).toBe(
'critical user skill'
)
expect(readFileSync(join(dir!, 'extensions', 'user-ext', 'ext.ts'), 'utf8')).toBe(
'user extension'
)
expect(readdirSync(join(dir!, 'extensions')).sort()).toEqual([
'orca-agent-status.ts',
'user-ext'
])
expect(JSON.parse(readFileSync(join(dir!, 'settings.json'), 'utf8'))).toEqual({
defaultProvider: 'amazon-bedrock',
hideThinkingBlock: true,
terminal: {
showImages: false,
clearOnShrink: true
}
})
expect(JSON.parse(readFileSync(join(piAgentDir, 'settings.json'), 'utf8'))).toEqual({
defaultProvider: 'amazon-bedrock',
hideThinkingBlock: false,
terminal: {
showImages: false,
clearOnShrink: false
}
})
})
it('mirrors a preexisting remote Pi agent dir instead of the default', () => {
const defaultAgentDir = join(homeDir, '.pi', 'agent')
const customAgentDir = join(homeDir, 'custom-pi-agent')
mkdirSync(defaultAgentDir, { recursive: true })
mkdirSync(join(customAgentDir, 'extensions'), { recursive: true })
writeFileSync(join(defaultAgentDir, 'auth.json'), 'default token')
writeFileSync(join(customAgentDir, 'auth.json'), 'custom token')
writeFileSync(join(customAgentDir, 'extensions', 'custom.ts'), 'custom extension')
manager.setSources({ piExtensionSource: '// pi extension' })
const dir = manager.materializePi('tab-custom-pi:0', customAgentDir)
expect(dir).not.toBeNull()
expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('custom token')
expect(readFileSync(join(dir!, 'extensions', 'custom.ts'), 'utf8')).toBe('custom extension')
expect(readFileSync(join(dir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe(
'// pi extension'
)
})
// Why: per-agent overlay source dir. The renderer picks Pi or OMP per
// launch, and the relay must mirror the right `~/.<kind>/agent` source —
// disk-presence guessing (always-Pi or first-exists) shadows the other
// agent's user extensions when both dirs exist on the remote disk.
describe('per-agent default source dir (no cross-agent fallback)', () => {
function seedAgentDir(dotDir: '.pi' | '.omp', tag: string): string {
const agentDir = join(homeDir, dotDir, 'agent')
mkdirSync(join(agentDir, 'extensions', `${tag}-ext`), { recursive: true })
writeFileSync(join(agentDir, 'extensions', `${tag}-ext`, 'ext.ts'), `${tag} extension`)
writeFileSync(join(agentDir, 'auth.json'), `${tag} token`)
return agentDir
}
it('launching pi with both ~/.pi/agent and ~/.omp/agent present mirrors ~/.pi/agent into pi-overlays', () => {
seedAgentDir('.pi', 'pi')
seedAgentDir('.omp', 'omp')
manager.setSources({ piExtensionSource: '// pi extension' })
const dir = manager.materializePi('tab-relay-pi-both:0', undefined, 'pi')
expect(dir).not.toBeNull()
// Pi overlays live under .orca-relay/pi-overlays, separate from OMP's tree.
expect(dir!).toMatch(/[\\/]\.orca-relay[\\/]pi-overlays[\\/]/)
expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('pi token')
const overlayExtensions = readdirSync(join(dir!, 'extensions')).sort()
expect(overlayExtensions).toContain('pi-ext')
expect(overlayExtensions).not.toContain('omp-ext')
})
it('launching omp with both ~/.pi/agent and ~/.omp/agent present mirrors ~/.omp/agent into omp-overlays', () => {
seedAgentDir('.pi', 'pi')
seedAgentDir('.omp', 'omp')
manager.setSources({ piExtensionSource: '// pi extension' })
const dir = manager.materializePi('tab-relay-omp-both:0', undefined, 'omp')
expect(dir).not.toBeNull()
// CRITICAL: OMP overlays live in a distinct subtree (.orca-relay/omp-overlays)
// so the remote box never mixes Pi and OMP overlay state for the same paneKey.
expect(dir!).toMatch(/[\\/]\.orca-relay[\\/]omp-overlays[\\/]/)
expect(dir!).not.toMatch(/[\\/]pi-overlays[\\/]/)
// Even though ~/.pi/agent exists, the OMP launch MUST mirror OMP's
// source dir. Cross-agent fallback would silently shadow the user's
// OMP extensions on the remote.
expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('omp token')
const overlayExtensions = readdirSync(join(dir!, 'extensions')).sort()
expect(overlayExtensions).toContain('omp-ext')
expect(overlayExtensions).not.toContain('pi-ext')
})
it('launching omp when only ~/.pi/agent exists does NOT mirror Pi state', () => {
// Why: missing OMP source dir on the remote must materialize the
// overlay from empty — Orca's status extension only, no Pi state
// cross-pollinated in.
seedAgentDir('.pi', 'pi')
expect(existsSync(join(homeDir, '.omp'))).toBe(false)
manager.setSources({ piExtensionSource: '// pi extension' })
const dir = manager.materializePi('tab-relay-omp-empty:0', undefined, 'omp')
expect(dir).not.toBeNull()
expect(dir!).toMatch(/[\\/]\.orca-relay[\\/]omp-overlays[\\/]/)
// Pi-only home must NOT leak into the OMP overlay.
expect(existsSync(join(dir!, 'auth.json'))).toBe(false)
const overlayExtensions = readdirSync(join(dir!, 'extensions')).sort()
expect(overlayExtensions).toEqual(['orca-agent-status.ts'])
expect(JSON.parse(readFileSync(join(dir!, 'settings.json'), 'utf8'))).toEqual({
hideThinkingBlock: true,
terminal: { clearOnShrink: true }
})
})
})
it('does not override a missing preexisting Pi agent dir', () => {
manager.setSources({ piExtensionSource: '// pi extension' })
expect(manager.materializePi('tab-missing-pi:0', join(homeDir, 'missing-pi'))).toBeNull()
})
it('clearOverlay removes opencode + every Pi-kind overlay root for an id', () => {
manager.setSources({
opencodePluginSource: 'opencode',
piExtensionSource: 'pi',
ompExtensionSource: 'omp'
})
const opencodeDir = manager.materializeOpenCode('tab-3:0')!
const piDir = manager.materializePi('tab-3:0', undefined, 'pi')!
const ompDir = manager.materializePi('tab-3:0', undefined, 'omp')!
// Sanity: each kind got its own subtree, not a shared one.
expect(piDir).not.toBe(ompDir)
expect(existsSync(opencodeDir)).toBe(true)
expect(existsSync(piDir)).toBe(true)
expect(existsSync(ompDir)).toBe(true)
manager.clearOverlay('tab-3:0')
expect(existsSync(opencodeDir)).toBe(false)
expect(existsSync(piDir)).toBe(false)
expect(existsSync(ompDir)).toBe(false)
})
it.skipIf(process.platform === 'win32')(
'clearOverlay removes OpenCode overlay symlinks without deleting their targets',
() => {
const userConfigDir = join(homeDir, 'company-opencode')
const linkedTarget = join(homeDir, 'linked-plugin-target')
mkdirSync(join(userConfigDir, 'plugins'), { recursive: true })
mkdirSync(linkedTarget, { recursive: true })
writeFileSync(join(linkedTarget, 'keep.js'), 'do not delete')
symlinkSync(linkedTarget, join(userConfigDir, 'plugins', 'linked-plugin'), 'dir')
manager.setSources({ opencodePluginSource: 'orca plugin' })
const dir = manager.materializeOpenCode('tab-opencode-symlink:0', userConfigDir)!
expect(existsSync(join(dir, 'plugins', 'linked-plugin'))).toBe(true)
manager.clearOverlay('tab-opencode-symlink:0')
expect(existsSync(dir)).toBe(false)
expect(readFileSync(join(linkedTarget, 'keep.js'), 'utf8')).toBe('do not delete')
}
)
it('produces stable overlay dirs for a given id (idempotent re-materialization)', () => {
manager.setSources({ opencodePluginSource: 'first' })
const dirA = manager.materializeOpenCode('tab-stable:0')!
manager.setSources({ opencodePluginSource: 'second' })
const dirB = manager.materializeOpenCode('tab-stable:0')!
expect(dirA).toBe(dirB)
expect(readFileSync(join(dirA, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe('second')
})
it('hashes unsafe pane ids into portable overlay directory names', () => {
manager.setSources({ opencodePluginSource: 'plugin' })
const dir = manager.materializeOpenCode('tab/with\\unsafe:chars\n0')
expect(dir).not.toBeNull()
expect(basename(dir!)).toMatch(/^[a-f0-9]{32}$/)
expect(dir).not.toContain('tab/with')
expect(existsSync(join(dir!, 'plugins', 'orca-opencode-status.js'))).toBe(true)
})
})
describe('resolvePiSourceAgentDir', () => {
it('uses only the selected kind source shadow when resolving inherited overlays', () => {
const env = {
HOME: mkdtempSync(join(tmpdir(), 'plugin-overlay-env-')),
PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_SOURCE_AGENT_DIR: '/user/.pi/agent'
}
try {
expect(resolvePiSourceAgentDir(env, undefined, 'pi')).toBe('/user/.pi/agent')
expect(resolvePiSourceAgentDir(env, undefined, 'omp')).toBeUndefined()
} finally {
rmSync(env.HOME, { recursive: true, force: true })
}
})
it('keeps explicit PI_CODING_AGENT_DIR values when they are not Orca overlays', () => {
const env = {
HOME: mkdtempSync(join(tmpdir(), 'plugin-overlay-env-')),
PI_CODING_AGENT_DIR: '/user/custom-omp-agent',
ORCA_PI_SOURCE_AGENT_DIR: '/user/.pi/agent'
}
try {
expect(resolvePiSourceAgentDir(env, undefined, 'omp')).toBe('/user/custom-omp-agent')
} finally {
rmSync(env.HOME, { recursive: true, force: true })
}
})
})