mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
887 lines
36 KiB
TypeScript
887 lines
36 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { spawnSync } from 'node:child_process'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import type * as ShellReadyModule from './shell-ready'
|
|
import { getZshShellReadyMarkerRegistrationBlock } from '../shell-templates'
|
|
|
|
async function importFreshShellReady(): Promise<typeof ShellReadyModule> {
|
|
vi.resetModules()
|
|
return import('./shell-ready')
|
|
}
|
|
|
|
const describePosix = process.platform === 'win32' ? describe.skip : describe
|
|
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
|
|
const itWithBash = hasBash ? it : it.skip
|
|
const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0
|
|
const itWithZsh = hasZsh ? it : it.skip
|
|
const hasFish = process.platform !== 'win32' && spawnSync('fish', ['--version']).status === 0
|
|
const itWithFish = hasFish ? it : it.skip
|
|
|
|
const SHELL_READY_MARKER_OUTPUT = '\x1b]777;orca-shell-ready\x07'
|
|
|
|
/** Minimal xterm.js-shaped answers to the capability queries fish emits at startup
|
|
* and again around every prompt. */
|
|
const TERMINAL_QUERY_REPLIES: readonly (readonly [string, string])[] = [
|
|
['\x1b[0c', '\x1b[?6c'], // primary device attributes
|
|
['\x1b[?u', '\x1b[?0u'], // kitty keyboard flags
|
|
['\x1b[6n', '\x1b[1;1R'], // cursor position report
|
|
['\x1b]11;?', '\x1b]11;rgb:0000/0000/0000\x1b\\'], // background colour
|
|
['\x1bP+q', '\x1bP0+r\x1b\\'] // XTGETTCAP (unsupported)
|
|
]
|
|
|
|
/** Derived, not hardcoded: a shorter carry than the longest query would silently
|
|
* stop matching sequences split across two PTY chunks. */
|
|
const QUERY_CARRY_LEN = Math.max(...TERMINAL_QUERY_REPLIES.map(([query]) => query.length))
|
|
|
|
// Why: the shell-ready marker fires from zle-line-init only on a real TTY, so spawn through node-pty not spawnSync.
|
|
async function runInteractiveZshLogin(args: {
|
|
tempHome: string
|
|
wrapperZdotdir: string
|
|
isDone: (output: string) => boolean
|
|
}): Promise<string> {
|
|
const pty = await import('node-pty')
|
|
// Why: -o noglobalrcs skips /etc/zsh/*, whose insecure fpath dirs make compinit block on a [y/n] prompt before the marker fires.
|
|
const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-l'], {
|
|
name: 'xterm-256color',
|
|
cols: 80,
|
|
rows: 24,
|
|
cwd: args.tempHome,
|
|
env: {
|
|
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
HOME: args.tempHome,
|
|
TERM: 'xterm-256color',
|
|
ZDOTDIR: args.wrapperZdotdir,
|
|
ORCA_ORIG_ZDOTDIR: args.tempHome,
|
|
ORCA_ZSHENV_SOURCE_DIR: args.tempHome,
|
|
ORCA_SHELL_READY_MARKER: '1'
|
|
}
|
|
})
|
|
let output = ''
|
|
let settle = (): void => {}
|
|
const done = new Promise<void>((resolve) => {
|
|
settle = resolve
|
|
})
|
|
const deadline = setTimeout(settle, 10_000)
|
|
proc.onData((chunk) => {
|
|
output += chunk
|
|
if (args.isDone(output)) {
|
|
settle()
|
|
}
|
|
})
|
|
await done
|
|
clearTimeout(deadline)
|
|
proc.kill()
|
|
return output
|
|
}
|
|
|
|
// Why: exercise an arbitrary interactive zsh rc (own ZDOTDIR, no wrapper) so a test can source the marker block directly.
|
|
async function runInteractiveZshRc(args: {
|
|
zdotdir: string
|
|
isDone: (output: string) => boolean
|
|
}): Promise<string> {
|
|
const pty = await import('node-pty')
|
|
// Why: -o noglobalrcs skips /etc/zsh/* so the CI runner's global compinit can't block on an insecure-directory [y/n] prompt.
|
|
const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-i'], {
|
|
name: 'xterm-256color',
|
|
cols: 80,
|
|
rows: 24,
|
|
cwd: args.zdotdir,
|
|
env: {
|
|
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
HOME: args.zdotdir,
|
|
TERM: 'xterm-256color',
|
|
ZDOTDIR: args.zdotdir,
|
|
ORCA_SHELL_READY_MARKER: '1'
|
|
}
|
|
})
|
|
let output = ''
|
|
let settle = (): void => {}
|
|
const done = new Promise<void>((resolve) => {
|
|
settle = resolve
|
|
})
|
|
const deadline = setTimeout(settle, 10_000)
|
|
proc.onData((chunk) => {
|
|
output += chunk
|
|
if (args.isDone(output)) {
|
|
settle()
|
|
}
|
|
})
|
|
await done
|
|
clearTimeout(deadline)
|
|
proc.kill()
|
|
return output
|
|
}
|
|
|
|
function runInteractiveBashRcfile(rcfileContent: string, tempDir: string): string {
|
|
const rcfile = join(tempDir, 'bash-osc133-rcfile')
|
|
writeFileSync(rcfile, rcfileContent)
|
|
|
|
const result = spawnSync(
|
|
'bash',
|
|
['-lc', 'bash --noprofile --rcfile "$1" -i 2>&1', 'bash', rcfile],
|
|
{
|
|
input: 'true\nfalse\nexit 0\n',
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
HOME: tempDir,
|
|
ORCA_SHELL_READY_MARKER: '1',
|
|
TERM: process.env.TERM || 'xterm'
|
|
},
|
|
timeout: 5000
|
|
}
|
|
)
|
|
|
|
expect(result.error).toBeUndefined()
|
|
expect(result.status).toBe(0)
|
|
return result.stdout
|
|
}
|
|
|
|
function expectBashOsc133Lifecycle(output: string): void {
|
|
const oscA = '\x1b]133;A\x07'
|
|
const oscC = '\x1b]133;C\x07'
|
|
const oscD = '\x1b]133;D;'
|
|
const firstPromptMarker = output.indexOf(oscA)
|
|
|
|
expect(firstPromptMarker).toBeGreaterThanOrEqual(0)
|
|
expect(output.slice(0, firstPromptMarker)).not.toContain(oscC)
|
|
expect(output.slice(0, firstPromptMarker)).not.toContain(oscD)
|
|
expect(output).toContain(`${oscD}0\x07${oscA}`)
|
|
expect(output).toContain(`${oscD}1\x07${oscA}`)
|
|
expect(output.split(oscC)).toHaveLength(4)
|
|
expect(output.split(oscD)).toHaveLength(3)
|
|
}
|
|
|
|
function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') {
|
|
expect(content).toContain('export ZDOTDIR="$_orca_home"')
|
|
expect(content).toContain(`source "$_orca_home/${fileName}"`)
|
|
expect(content).toContain('export ZDOTDIR="$_orca_wrapper_zdotdir"')
|
|
}
|
|
|
|
function expectFinalZdotdirRestoreContext(content: string) {
|
|
expect(content).toContain("after Orca's last wrapper file has loaded")
|
|
expect(content).toContain('export ZDOTDIR="$_orca_home"')
|
|
}
|
|
|
|
describePosix('daemon shell-ready launch config', () => {
|
|
let previousUserDataPath: string | undefined
|
|
let previousOrcaOrigZdotdir: string | undefined
|
|
let userDataPath: string
|
|
|
|
beforeEach(() => {
|
|
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
|
previousOrcaOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
|
|
delete process.env.ORCA_ORIG_ZDOTDIR
|
|
userDataPath = mkdtempSync(join(tmpdir(), 'daemon-shell-ready-test-'))
|
|
process.env.ORCA_USER_DATA_PATH = userDataPath
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (previousUserDataPath === undefined) {
|
|
delete process.env.ORCA_USER_DATA_PATH
|
|
} else {
|
|
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
|
}
|
|
if (previousOrcaOrigZdotdir === undefined) {
|
|
delete process.env.ORCA_ORIG_ZDOTDIR
|
|
} else {
|
|
process.env.ORCA_ORIG_ZDOTDIR = previousOrcaOrigZdotdir
|
|
}
|
|
rmSync(userDataPath, { recursive: true, force: true })
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('stores wrapper rcfiles under durable userData instead of tmp', async () => {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
|
|
const config = getShellReadyLaunchConfig('/bin/bash')
|
|
const rcfile = join(userDataPath, 'shell-ready', 'bash', 'rcfile')
|
|
|
|
expect(config.args).toEqual(['--rcfile', rcfile])
|
|
expect(existsSync(rcfile)).toBe(true)
|
|
})
|
|
|
|
it('rewrites wrappers when a long-lived daemon finds a missing rcfile', async () => {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
const rcfile = join(userDataPath, 'shell-ready', 'bash', 'rcfile')
|
|
|
|
getShellReadyLaunchConfig('/bin/bash')
|
|
rmSync(rcfile)
|
|
|
|
expect(existsSync(rcfile)).toBe(false)
|
|
getShellReadyLaunchConfig('/bin/bash')
|
|
expect(existsSync(rcfile)).toBe(true)
|
|
})
|
|
|
|
it('points zsh launch config at durable wrapper files', async () => {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
|
|
const config = getShellReadyLaunchConfig('/bin/zsh')
|
|
|
|
expect(config.args).toEqual(['-l'])
|
|
expect(config.env.ZDOTDIR).toBe(join(userDataPath, 'shell-ready', 'zsh'))
|
|
expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true)
|
|
})
|
|
|
|
it('extends the startup barrier to fish so launch commands queue until the prompt', async () => {
|
|
const { shellPathSupportsPtyStartupBarrier, supportsPtyStartupBarrier } =
|
|
await importFreshShellReady()
|
|
|
|
expect(shellPathSupportsPtyStartupBarrier('/opt/homebrew/bin/fish')).toBe(true)
|
|
expect(supportsPtyStartupBarrier({ SHELL: '/usr/local/bin/fish' })).toBe(true)
|
|
// Why: unwrapped shells must stay off the barrier or their first command queues forever.
|
|
expect(shellPathSupportsPtyStartupBarrier('/usr/bin/tcsh')).toBe(false)
|
|
})
|
|
|
|
it('wraps fish launches with a fish_prompt shell-ready marker init command', async () => {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
|
|
const config = getShellReadyLaunchConfig('/opt/homebrew/bin/fish')
|
|
|
|
expect(config.supportsReadyMarker).toBe(true)
|
|
expect(config.env).toEqual({ ORCA_SHELL_READY_MARKER: '1' })
|
|
expect(config.args?.slice(0, 2)).toEqual(['-l', '-C'])
|
|
const init = config.args?.[2] ?? ''
|
|
expect(init).toContain('--on-event fish_prompt')
|
|
// Why `builtin`: a user-defined printf function would swallow the marker and
|
|
// stall every launch on the ready timeout.
|
|
expect(init).toContain('builtin printf "\\033]777;orca-shell-ready\\007"')
|
|
// Why: the marker must fire once; a repeating marker would corrupt later output scans.
|
|
expect(init).toContain('functions -e __orca_shell_ready_marker')
|
|
})
|
|
|
|
it('keeps attribution-only fish spawns unwrapped', async () => {
|
|
const { getAttributionShellLaunchConfig } = await importFreshShellReady()
|
|
|
|
const config = getAttributionShellLaunchConfig('/opt/homebrew/bin/fish')
|
|
|
|
expect(config).toEqual({ args: null, env: {}, supportsReadyMarker: false })
|
|
})
|
|
|
|
itWithFish(
|
|
'emits the marker at the first real fish prompt and executes a post-marker command',
|
|
async () => {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
const config = getShellReadyLaunchConfig('fish')
|
|
const tempHome = mkdtempSync(join(tmpdir(), 'fish-shell-ready-'))
|
|
const sentinel = join(tempHome, 'launched')
|
|
const erased = join(tempHome, 'marker-erased')
|
|
const stillRegistered = join(tempHome, 'marker-still-registered')
|
|
try {
|
|
mkdirSync(join(tempHome, '.config', 'fish'), { recursive: true })
|
|
// Why: mimic a slow prompt integration (Starship) — init work before the first prompt.
|
|
writeFileSync(
|
|
join(tempHome, '.config', 'fish', 'config.fish'),
|
|
'command sleep 0.2\nfunction fish_prompt\n printf "> "\nend\n'
|
|
)
|
|
const pty = await import('node-pty')
|
|
const proc = pty.spawn('fish', config.args ?? [], {
|
|
name: 'xterm-256color',
|
|
cols: 80,
|
|
rows: 24,
|
|
cwd: tempHome,
|
|
env: {
|
|
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
HOME: tempHome,
|
|
TERM: 'xterm-256color',
|
|
...config.env
|
|
}
|
|
})
|
|
let output = ''
|
|
let commandWritten = false
|
|
let erasureProbeWritten = false
|
|
let queryCarry = ''
|
|
let settle = (): void => {}
|
|
const done = new Promise<void>((resolve) => {
|
|
settle = resolve
|
|
})
|
|
const deadline = setTimeout(settle, 10_000)
|
|
// Why: settling on the first sentinel observes only one post-marker prompt,
|
|
// so a marker that never erased itself still looks single. Drive a second
|
|
// command and settle on its result, which also probes the erase directly.
|
|
const sentinelPoll = setInterval(() => {
|
|
if (commandWritten && !erasureProbeWritten && existsSync(sentinel)) {
|
|
erasureProbeWritten = true
|
|
proc.write(
|
|
`functions -q __orca_shell_ready_marker; and touch ${stillRegistered}; or touch ${erased}\n`
|
|
)
|
|
return
|
|
}
|
|
if (erasureProbeWritten && (existsSync(erased) || existsSync(stillRegistered))) {
|
|
settle()
|
|
}
|
|
}, 50)
|
|
proc.onData((chunk) => {
|
|
output += chunk
|
|
// Why: fish stalls its first prompt 10s waiting on these and re-queries
|
|
// each prompt, so answer every occurrence — an unanswered query makes
|
|
// fish swallow the post-marker command as its reply.
|
|
const carriedLength = queryCarry.length
|
|
const scan = queryCarry + chunk
|
|
queryCarry = scan.slice(-QUERY_CARRY_LEN)
|
|
for (const [query, reply] of TERMINAL_QUERY_REPLIES) {
|
|
for (
|
|
let at = scan.indexOf(query);
|
|
at !== -1;
|
|
at = scan.indexOf(query, at + query.length)
|
|
) {
|
|
// Why: a query wholly inside the carry was answered on the previous
|
|
// chunk; replying again would land in fish's stdin as typed input.
|
|
if (at + query.length > carriedLength) {
|
|
proc.write(reply)
|
|
}
|
|
}
|
|
}
|
|
if (!commandWritten && output.includes(SHELL_READY_MARKER_OUTPUT)) {
|
|
commandWritten = true
|
|
// Why: mirror PostReadyFlushGate — flush shortly after the post-marker prompt draw.
|
|
setTimeout(() => proc.write(`touch ${sentinel}\n`), 50)
|
|
}
|
|
})
|
|
await done
|
|
clearTimeout(deadline)
|
|
clearInterval(sentinelPoll)
|
|
proc.kill()
|
|
|
|
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
|
|
expect(output.split(SHELL_READY_MARKER_OUTPUT)).toHaveLength(2)
|
|
expect(existsSync(sentinel)).toBe(true)
|
|
// Why: asserts the erase directly rather than inferring it from the marker
|
|
// count, which only holds once enough prompts have been drawn to expose it.
|
|
expect(existsSync(erased)).toBe(true)
|
|
expect(existsSync(stillRegistered)).toBe(false)
|
|
} finally {
|
|
rmSync(tempHome, { recursive: true, force: true })
|
|
}
|
|
},
|
|
15_000
|
|
)
|
|
|
|
it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => {
|
|
// Why: an Orca-PTY parent has ZDOTDIR=.../shell-ready/zsh; propagating it makes the wrapper source itself (recursion loop).
|
|
const previousZdotdir = process.env.ZDOTDIR
|
|
const previousHome = process.env.HOME
|
|
process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh'
|
|
process.env.HOME = '/Users/alice'
|
|
try {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
const config = getShellReadyLaunchConfig('/bin/zsh')
|
|
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice')
|
|
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice')
|
|
} finally {
|
|
if (previousZdotdir === undefined) {
|
|
delete process.env.ZDOTDIR
|
|
} else {
|
|
process.env.ZDOTDIR = previousZdotdir
|
|
}
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME
|
|
} else {
|
|
process.env.HOME = previousHome
|
|
}
|
|
}
|
|
})
|
|
|
|
it('uses inherited ORCA_ORIG_ZDOTDIR when ZDOTDIR is an Orca wrapper dir', async () => {
|
|
const previousZdotdir = process.env.ZDOTDIR
|
|
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
|
|
const previousHome = process.env.HOME
|
|
process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh'
|
|
process.env.ORCA_ORIG_ZDOTDIR = '/Users/alice/.config/zsh'
|
|
process.env.HOME = '/Users/alice'
|
|
try {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
const config = getShellReadyLaunchConfig('/bin/zsh')
|
|
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh')
|
|
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice')
|
|
} finally {
|
|
if (previousZdotdir === undefined) {
|
|
delete process.env.ZDOTDIR
|
|
} else {
|
|
process.env.ZDOTDIR = previousZdotdir
|
|
}
|
|
if (previousOrigZdotdir === undefined) {
|
|
delete process.env.ORCA_ORIG_ZDOTDIR
|
|
} else {
|
|
process.env.ORCA_ORIG_ZDOTDIR = previousOrigZdotdir
|
|
}
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME
|
|
} else {
|
|
process.env.HOME = previousHome
|
|
}
|
|
}
|
|
})
|
|
|
|
it('falls back to HOME when inherited ORCA_ORIG_ZDOTDIR points at a wrapper dir', async () => {
|
|
const previousZdotdir = process.env.ZDOTDIR
|
|
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
|
|
const previousHome = process.env.HOME
|
|
delete process.env.ZDOTDIR
|
|
process.env.ORCA_ORIG_ZDOTDIR = '/some/other/orca/shell-ready/zsh'
|
|
process.env.HOME = '/Users/alice'
|
|
try {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
const config = getShellReadyLaunchConfig('/bin/zsh')
|
|
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice')
|
|
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice')
|
|
} finally {
|
|
if (previousZdotdir === undefined) {
|
|
delete process.env.ZDOTDIR
|
|
} else {
|
|
process.env.ZDOTDIR = previousZdotdir
|
|
}
|
|
if (previousOrigZdotdir === undefined) {
|
|
delete process.env.ORCA_ORIG_ZDOTDIR
|
|
} else {
|
|
process.env.ORCA_ORIG_ZDOTDIR = previousOrigZdotdir
|
|
}
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME
|
|
} else {
|
|
process.env.HOME = previousHome
|
|
}
|
|
}
|
|
})
|
|
|
|
it('writes zsh wrappers that guard against ORCA_ORIG_ZDOTDIR self-loops', async () => {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
|
|
getShellReadyLaunchConfig('/bin/zsh')
|
|
|
|
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
|
|
const zprofile = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zprofile'), 'utf8')
|
|
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
|
|
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
|
|
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
|
|
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
|
|
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
|
|
expectZdotdirSourceContext(zprofile, '.zprofile')
|
|
expectZdotdirSourceContext(zshrc, '.zshrc')
|
|
expectZdotdirSourceContext(zlogin, '.zlogin')
|
|
expectFinalZdotdirRestoreContext(zshrc)
|
|
expectFinalZdotdirRestoreContext(zlogin)
|
|
})
|
|
|
|
it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
|
|
getShellReadyLaunchConfig('/bin/zsh')
|
|
|
|
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
|
|
expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark')
|
|
expect(zlogin).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"')
|
|
expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"')
|
|
// Why: add-zle-hook-widget aborts its chain when an earlier hook exits non-zero, so don't register the marker through it.
|
|
expect(zlogin).not.toContain('add-zle-hook-widget line-init')
|
|
// Why: re-source guard — skip re-capturing when already the bound widget so the prior chain survives a second source.
|
|
expect(zlogin).toContain('== "user:__orca_prompt_mark"')
|
|
})
|
|
|
|
// Why: oh-my-zsh vi-mode's zle-line-init returns non-zero; add-zle-hook-widget then aborts the chain and the marker never fires.
|
|
itWithZsh(
|
|
'emits the shell-ready marker even when a user zle-line-init widget fails (oh-my-zsh vi-mode shape)',
|
|
async () => {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
const config = getShellReadyLaunchConfig('/bin/zsh')
|
|
const tempHome = mkdtempSync(join(tmpdir(), 'orca-zsh-vi-mode-'))
|
|
writeFileSync(
|
|
join(tempHome, '.zshrc'),
|
|
[
|
|
'function zle-line-init() {',
|
|
' [[ "${VI_MODE_SET_CURSOR:-}" = true ]] || return',
|
|
'}',
|
|
'zle -N zle-line-init',
|
|
''
|
|
].join('\n')
|
|
)
|
|
try {
|
|
const output = await runInteractiveZshLogin({
|
|
tempHome,
|
|
wrapperZdotdir: config.env.ZDOTDIR,
|
|
isDone: (current) => current.includes(SHELL_READY_MARKER_OUTPUT)
|
|
})
|
|
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
|
|
} finally {
|
|
rmSync(tempHome, { recursive: true, force: true })
|
|
}
|
|
},
|
|
15_000
|
|
)
|
|
|
|
itWithZsh(
|
|
'still runs user add-zle-hook-widget line-init hooks after the marker',
|
|
async () => {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
const config = getShellReadyLaunchConfig('/bin/zsh')
|
|
const tempHome = mkdtempSync(join(tmpdir(), 'orca-zsh-azhw-'))
|
|
const userHookOutput = 'ORCA-TEST-USER-HOOK'
|
|
writeFileSync(
|
|
join(tempHome, '.zshrc'),
|
|
[
|
|
`__orca_test_line_init_hook() { printf "${userHookOutput}" }`,
|
|
'autoload -Uz add-zle-hook-widget',
|
|
'zle -N __orca_test_line_init_hook',
|
|
'add-zle-hook-widget line-init __orca_test_line_init_hook',
|
|
''
|
|
].join('\n')
|
|
)
|
|
try {
|
|
const output = await runInteractiveZshLogin({
|
|
tempHome,
|
|
wrapperZdotdir: config.env.ZDOTDIR,
|
|
isDone: (current) =>
|
|
current.includes(SHELL_READY_MARKER_OUTPUT) && current.includes(userHookOutput)
|
|
})
|
|
// Why: the marker widget chains to the prior widget, so a user-registered azhw dispatcher must keep dispatching.
|
|
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
|
|
expect(output).toContain(userHookOutput)
|
|
expect(output.indexOf(SHELL_READY_MARKER_OUTPUT)).toBeLessThan(
|
|
output.indexOf(userHookOutput)
|
|
)
|
|
} finally {
|
|
rmSync(tempHome, { recursive: true, force: true })
|
|
}
|
|
},
|
|
15_000
|
|
)
|
|
|
|
// Why: a re-source (nested Orca, manual) must stay idempotent — keep chaining the user's original zle-line-init.
|
|
itWithZsh(
|
|
'keeps chaining the prior zle-line-init widget when the marker block is sourced twice',
|
|
async () => {
|
|
const zdotdir = mkdtempSync(join(tmpdir(), 'orca-zsh-resource-'))
|
|
const userHookOutput = 'ORCA-TEST-PRIOR-WIDGET'
|
|
const block = getZshShellReadyMarkerRegistrationBlock('\\033]777;orca-shell-ready\\007')
|
|
writeFileSync(
|
|
join(zdotdir, '.zshrc'),
|
|
[
|
|
// A user widget that mimics oh-my-zsh vi-mode owning zle-line-init.
|
|
`__orca_test_prior_widget() { printf "${userHookOutput}" }`,
|
|
'zle -N zle-line-init __orca_test_prior_widget',
|
|
block,
|
|
// Second source of the exact same block — must not drop the chain.
|
|
block,
|
|
''
|
|
].join('\n')
|
|
)
|
|
try {
|
|
const output = await runInteractiveZshRc({
|
|
zdotdir,
|
|
isDone: (current) =>
|
|
current.includes(SHELL_READY_MARKER_OUTPUT) && current.includes(userHookOutput)
|
|
})
|
|
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
|
|
expect(output).toContain(userHookOutput)
|
|
expect(output.indexOf(SHELL_READY_MARKER_OUTPUT)).toBeLessThan(
|
|
output.indexOf(userHookOutput)
|
|
)
|
|
// Why: idempotent — the marker must fire exactly once per prompt, not duplicated by the second registration.
|
|
expect(output.split(SHELL_READY_MARKER_OUTPUT)).toHaveLength(2)
|
|
} finally {
|
|
rmSync(zdotdir, { recursive: true, force: true })
|
|
}
|
|
},
|
|
15_000
|
|
)
|
|
|
|
it('writes wrappers without restoring Pi/OMP homes after user startup files', async () => {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
|
|
getShellReadyLaunchConfig('/bin/zsh')
|
|
getShellReadyLaunchConfig('/bin/bash')
|
|
|
|
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
|
|
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
|
|
const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
|
|
const restoreLine =
|
|
'[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
|
|
const mimoRestoreLine =
|
|
'[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"'
|
|
const codexRestoreLine =
|
|
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
|
|
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
|
|
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
|
|
const primeWrapperLine =
|
|
'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"'
|
|
expect(zshrc).toContain(restoreLine)
|
|
expect(zlogin).toContain(restoreLine)
|
|
expect(bashRc).toContain(restoreLine)
|
|
expect(zshrc).toContain(mimoRestoreLine)
|
|
expect(zlogin).toContain(mimoRestoreLine)
|
|
expect(bashRc).toContain(mimoRestoreLine)
|
|
expect(zshrc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
|
|
expect(zlogin).not.toContain('ORCA_PI_CODING_AGENT_DIR')
|
|
expect(bashRc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
|
|
expect(zshrc).toContain(codexRestoreLine)
|
|
expect(zlogin).toContain(codexRestoreLine)
|
|
expect(zshrc).toContain(agentTeamsPathRestoreLine)
|
|
expect(zlogin).toContain(agentTeamsPathRestoreLine)
|
|
expect(bashRc).toContain(agentTeamsPathRestoreLine)
|
|
expect(bashRc).toContain(codexRestoreLine)
|
|
expect(zshrc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
|
|
expect(zlogin).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
|
|
expect(bashRc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
|
|
expect(zshrc).toContain(ompWrapperLine)
|
|
expect(zlogin).toContain(ompWrapperLine)
|
|
expect(bashRc).toContain(ompWrapperLine)
|
|
expect(zshrc).toContain(primeWrapperLine)
|
|
expect(zlogin).toContain(primeWrapperLine)
|
|
expect(bashRc).toContain(primeWrapperLine)
|
|
})
|
|
|
|
// Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows.
|
|
it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
|
|
getShellReadyLaunchConfig('/bin/zsh')
|
|
getShellReadyLaunchConfig('/bin/bash')
|
|
|
|
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
|
|
const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
|
|
|
|
expect(bashRc).toContain('printf "\\033]133;D;%s\\007"')
|
|
expect(bashRc).toContain('printf "\\033]133;C\\007"')
|
|
// precmd is prepended (captures $? first), epilogue appended last, so a framework needing last position stays between them.
|
|
expect(bashRc).toContain(
|
|
'PROMPT_COMMAND="__orca_osc133_precmd${PROMPT_COMMAND:+;${PROMPT_COMMAND}};__orca_osc133_epilogue"'
|
|
)
|
|
// DEBUG is armed after PROMPT_COMMAND setup so rcfile commands aren't seen as foreground; lastIndexOf skips the epilogue's re-arm.
|
|
expect(bashRc.lastIndexOf("trap '__orca_osc133_preexec' DEBUG")).toBeGreaterThan(
|
|
bashRc.indexOf('PROMPT_COMMAND="__orca_osc133_precmd')
|
|
)
|
|
expect(zshrc).toContain('printf "\\033]133;D;%s\\007"')
|
|
expect(zshrc).toContain('printf "\\033]133;C\\007"')
|
|
})
|
|
|
|
itWithBash(
|
|
'runs the daemon bash wrapper without fake C/D markers before the first prompt',
|
|
async () => {
|
|
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
|
|
|
|
const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
|
|
|
|
expectBashOsc133Lifecycle(output)
|
|
}
|
|
)
|
|
|
|
itWithBash(
|
|
'preserves prompt hooks and existing DEBUG traps without fake command markers',
|
|
async () => {
|
|
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
|
|
writeFileSync(
|
|
join(userDataPath, '.bash_profile'),
|
|
[
|
|
'PROMPT_COMMAND=\'AFTER_FIRST_PROMPT=1; printf "PROMPT_HOOK\\n"\'',
|
|
'trap \'if [[ -n "${AFTER_FIRST_PROMPT:-}" ]]; then\n printf "USER_DEBUG_AFTER\\n"\nfi\' DEBUG'
|
|
].join('\n')
|
|
)
|
|
|
|
const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
|
|
|
|
expect(output).toContain('PROMPT_HOOK')
|
|
expect(output).toContain('USER_DEBUG_AFTER')
|
|
expectBashOsc133Lifecycle(output)
|
|
}
|
|
)
|
|
|
|
itWithBash(
|
|
'still emits 133;C when bash-preexec re-arms the DEBUG trap at first prompt',
|
|
async () => {
|
|
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
|
|
// Minimal bash-preexec imitation: re-arms its own DEBUG trap from PROMPT_COMMAND at first prompt, silencing Orca's trap.
|
|
writeFileSync(
|
|
join(userDataPath, '.bash_profile'),
|
|
[
|
|
'preexec_functions=()',
|
|
'__bp_preexec_invoke_exec() {',
|
|
' [[ -n "${__bp_interactive_mode:-}" ]] || return',
|
|
' __bp_interactive_mode=""',
|
|
' local f',
|
|
' for f in "${preexec_functions[@]}"; do "$f" "$BASH_COMMAND"; done',
|
|
'}',
|
|
"__bp_arm() { __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }",
|
|
'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"'
|
|
].join('\n')
|
|
)
|
|
|
|
const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
|
|
|
|
expectBashOsc133Lifecycle(output)
|
|
}
|
|
)
|
|
|
|
itWithBash(
|
|
'dispatches a non-empty preexec_functions against the real command, not Orca hooks',
|
|
async () => {
|
|
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
|
|
// Why: the epilogue chains bash-preexec's re-armed DEBUG trap, so a real preexec callback must fire against the user's command.
|
|
writeFileSync(
|
|
join(userDataPath, '.bash_profile'),
|
|
[
|
|
'preexec_functions=(__user_preexec)',
|
|
'__user_preexec() { printf \'USER_PREEXEC:%s\\n\' "$1"; }',
|
|
'__bp_inside=0',
|
|
'__bp_last_hist=""',
|
|
'__bp_preexec_invoke_exec() {',
|
|
' (( __bp_inside > 0 )) && return',
|
|
' [[ -n "${__bp_interactive_mode:-}" ]] || return',
|
|
' local __bp_inside=1',
|
|
' local this_command',
|
|
' this_command="$(builtin history 1)"',
|
|
' this_command="${this_command#"${this_command%%[![:space:]]*}"}"',
|
|
' this_command="${this_command#* }"',
|
|
' this_command="${this_command#"${this_command%%[![:space:]]*}"}"',
|
|
' [[ -n "$this_command" && "$this_command" != "$__bp_last_hist" ]] || return',
|
|
' __bp_last_hist="$this_command"',
|
|
' __bp_interactive_mode=""',
|
|
' local f',
|
|
' for f in "${preexec_functions[@]}"; do "$f" "$this_command"; done',
|
|
'}',
|
|
"__bp_arm() { set -o functrace; __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }",
|
|
'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"'
|
|
].join('\n')
|
|
)
|
|
|
|
const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
|
|
|
|
expectBashOsc133Lifecycle(output)
|
|
expect(output).toContain('USER_PREEXEC:true')
|
|
expect(output).toContain('USER_PREEXEC:false')
|
|
expect(output).not.toContain('USER_PREEXEC:__orca_osc133')
|
|
expect(output).not.toContain('USER_PREEXEC:__bp_')
|
|
}
|
|
)
|
|
|
|
itWithBash('normalizes array PROMPT_COMMAND hooks so bash 3.2 still runs cleanup', async () => {
|
|
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
|
|
writeFileSync(
|
|
join(userDataPath, '.bash_profile'),
|
|
'PROMPT_COMMAND=(\'AFTER_ARRAY_PROMPT=1; printf "PROMPT_ARRAY\\n"\')\n'
|
|
)
|
|
|
|
const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
|
|
|
|
expect(output).toContain('PROMPT_ARRAY')
|
|
expectBashOsc133Lifecycle(output)
|
|
})
|
|
|
|
it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => {
|
|
// Why: only the wrapper self-loop should be rejected; a real user ZDOTDIR must round-trip so their configs load.
|
|
const previousZdotdir = process.env.ZDOTDIR
|
|
process.env.ZDOTDIR = '/Users/alice/.config/zsh'
|
|
try {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
const config = getShellReadyLaunchConfig('/bin/zsh')
|
|
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh')
|
|
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice/.config/zsh')
|
|
} finally {
|
|
if (previousZdotdir === undefined) {
|
|
delete process.env.ZDOTDIR
|
|
} else {
|
|
process.env.ZDOTDIR = previousZdotdir
|
|
}
|
|
}
|
|
})
|
|
|
|
it('rejects inherited ZDOTDIR ending in /shell-ready/zsh even with a trailing slash', async () => {
|
|
// Why: a trailing slash bypasses `endsWith('/shell-ready/zsh')`, reintroducing the recursion loop if unguarded.
|
|
const previousZdotdir = process.env.ZDOTDIR
|
|
const previousHome = process.env.HOME
|
|
process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh/'
|
|
process.env.HOME = '/Users/alice'
|
|
try {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
const config = getShellReadyLaunchConfig('/bin/zsh')
|
|
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice')
|
|
} finally {
|
|
if (previousZdotdir === undefined) {
|
|
delete process.env.ZDOTDIR
|
|
} else {
|
|
process.env.ZDOTDIR = previousZdotdir
|
|
}
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME
|
|
} else {
|
|
process.env.HOME = previousHome
|
|
}
|
|
}
|
|
})
|
|
|
|
it('falls back to HOME when ZDOTDIR is only slashes (e.g. "/")', async () => {
|
|
// Why: a bare `/` normalizes to empty (never a real config root), so fall back to HOME as when ZDOTDIR is unset.
|
|
const previousZdotdir = process.env.ZDOTDIR
|
|
const previousHome = process.env.HOME
|
|
process.env.ZDOTDIR = '/'
|
|
process.env.HOME = '/Users/alice'
|
|
try {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
const config = getShellReadyLaunchConfig('/bin/zsh')
|
|
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice')
|
|
} finally {
|
|
if (previousZdotdir === undefined) {
|
|
delete process.env.ZDOTDIR
|
|
} else {
|
|
process.env.ZDOTDIR = previousZdotdir
|
|
}
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME
|
|
} else {
|
|
process.env.HOME = previousHome
|
|
}
|
|
}
|
|
})
|
|
|
|
it('preserves ZDOTDIR that contains /shell-ready/zsh as a substring but does not end with it', async () => {
|
|
// Why: guard must match suffix not substring — `/Users/alice/shell-ready/zsh-custom` must round-trip unchanged.
|
|
const previousZdotdir = process.env.ZDOTDIR
|
|
process.env.ZDOTDIR = '/Users/alice/shell-ready/zsh-custom'
|
|
try {
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
const config = getShellReadyLaunchConfig('/bin/zsh')
|
|
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/shell-ready/zsh-custom')
|
|
} finally {
|
|
if (previousZdotdir === undefined) {
|
|
delete process.env.ZDOTDIR
|
|
} else {
|
|
process.env.ZDOTDIR = previousZdotdir
|
|
}
|
|
}
|
|
})
|
|
|
|
it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => {
|
|
// Why: PR #1737 sourced .zshenv in a wrapper function, breaking "typeset -U path"; keep it at zsh top level.
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
|
|
getShellReadyLaunchConfig('/bin/zsh')
|
|
|
|
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
|
|
|
|
expect(zshenv).toContain('unset ZDOTDIR')
|
|
expect(zshenv).toContain('_orca_zshenv_source_dir="${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"')
|
|
expect(zshenv).toContain('source "${_orca_zshenv_path}"')
|
|
expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"')
|
|
expect(zshenv).toContain(
|
|
'export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"'
|
|
)
|
|
expect(zshenv).toContain('export ZDOTDIR=')
|
|
})
|
|
|
|
it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => {
|
|
// Why: when user .zshenv sets no ZDOTDIR, the wrapper falls back to spawn-env ORCA_ORIG_ZDOTDIR, then HOME.
|
|
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
|
|
|
getShellReadyLaunchConfig('/bin/zsh')
|
|
|
|
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
|
|
|
|
// Save spawn-env value before sourcing user .zshenv
|
|
expect(zshenv).toContain('_orca_spawn_orig_zdotdir="${ORCA_ORIG_ZDOTDIR:-}"')
|
|
|
|
// Fallback chain: discovered → normalized spawn-env path → HOME
|
|
expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}')
|
|
})
|
|
})
|