mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 16:02:49 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/publish-submission-positions
This commit is contained in:
@@ -63,7 +63,17 @@ async function runSearch(
|
||||
): Promise<{ call: ReturnType<typeof vi.fn>; output: string }> {
|
||||
const call = options.error
|
||||
? vi.fn().mockRejectedValue(options.error)
|
||||
: vi.fn().mockResolvedValue(envelope(options.result ?? resultsResponse))
|
||||
: vi
|
||||
.fn()
|
||||
.mockImplementation((method: string) =>
|
||||
Promise.resolve(
|
||||
envelope(
|
||||
method === 'aiVault.searchStatus'
|
||||
? statusResponse
|
||||
: (options.result ?? resultsResponse)
|
||||
)
|
||||
)
|
||||
)
|
||||
const lines: string[] = []
|
||||
vi.spyOn(console, 'log').mockImplementation((value: unknown) => {
|
||||
lines.push(String(value))
|
||||
@@ -161,9 +171,13 @@ describe('orca search over the runtime RPC', () => {
|
||||
]
|
||||
]
|
||||
|
||||
it.each(flagCases)('sends %s', async (_name, flags, params) => {
|
||||
it.each(flagCases)('sends %s', async (name, flags, params) => {
|
||||
const { call } = await runSearch(flags)
|
||||
|
||||
expect(call).toHaveBeenCalledTimes(name === 'filters' ? 2 : 1)
|
||||
if (name === 'filters') {
|
||||
expect(call).toHaveBeenNthCalledWith(1, 'aiVault.searchStatus', {})
|
||||
}
|
||||
expect(call).toHaveBeenCalledWith('aiVault.searchSessions', {
|
||||
...params,
|
||||
supportedAgents: [...AI_VAULT_AGENTS],
|
||||
|
||||
@@ -30,6 +30,12 @@ __orca_restore_agent_teams_path() {
|
||||
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
# Why: user startup files may set the default OpenCode config after Orca's
|
||||
# spawn env; restore the Orca-managed config dir before the first prompt.
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
|
||||
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
|
||||
# which prints a warning above every command under warn_create_global.
|
||||
builtin typeset -g __orca_in_command=1
|
||||
}
|
||||
__orca_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) || return 0
|
||||
local __orca_direct_line_init=0
|
||||
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
|
||||
__orca_deferred_init
|
||||
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
|
||||
zle zle-line-init "$@"
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
|
||||
local __orca_prev_line_init_fn=""
|
||||
__orca_prompt_mark "$@"
|
||||
fi
|
||||
}
|
||||
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
|
||||
__orca_deferred_sched_init() {
|
||||
local __orca_prompt_status=$?
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
|
||||
builtin unset __orca_deferred_sched_armed
|
||||
builtin unfunction __orca_deferred_sched_init
|
||||
return $__orca_prompt_status
|
||||
}
|
||||
__orca_arm_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
|
||||
if (( ${+widgets[zle-line-init]} )); then
|
||||
zle -A zle-line-init __orca_saved_line_init
|
||||
fi
|
||||
zle -N zle-line-init __orca_deferred_line_init
|
||||
fi
|
||||
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
|
||||
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
|
||||
fi
|
||||
}
|
||||
__orca_deferred_init() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
@@ -54,12 +88,27 @@ __orca_deferred_init() {
|
||||
(( $+_orca_deferred_init_done )) && return 0
|
||||
builtin typeset -g _orca_deferred_init_done=1
|
||||
builtin typeset -g precmd_functions
|
||||
if (( ${+widgets[__orca_saved_line_init]} )); then
|
||||
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -A __orca_saved_line_init zle-line-init
|
||||
fi
|
||||
zle -D __orca_saved_line_init
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -D zle-line-init
|
||||
fi
|
||||
if __orca_has_feature markers; then
|
||||
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
|
||||
(( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
|
||||
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
|
||||
else
|
||||
precmd_functions=(${precmd_functions:#__orca_deferred_init})
|
||||
fi
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
|
||||
__orca_restore_agent_teams_path() {
|
||||
@@ -202,13 +251,25 @@ __orca_deferred_init() {
|
||||
# the permanent hook has not run yet and the first prompt would lose its mark.
|
||||
__orca_has_feature markers && __orca_osc133_precmd
|
||||
builtin unset _orca_shell_features _orca_histfile
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature
|
||||
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
|
||||
local __orca_widget __orca_line_init_bound=0
|
||||
for __orca_widget in "${(v)widgets[@]}"; do
|
||||
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
|
||||
__orca_line_init_bound=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
|
||||
}
|
||||
{
|
||||
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
|
||||
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
|
||||
} always {
|
||||
builtin unset _orca_user_zshenv
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
if (( ! $+_orca_deferred_init_done )); then
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
__orca_arm_deferred_line_init
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ __orca_restore_agent_teams_path() {
|
||||
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then
|
||||
if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then
|
||||
export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}"
|
||||
|
||||
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
|
||||
# which prints a warning above every command under warn_create_global.
|
||||
builtin typeset -g __orca_in_command=1
|
||||
}
|
||||
__orca_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) || return 0
|
||||
local __orca_direct_line_init=0
|
||||
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
|
||||
__orca_deferred_init
|
||||
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
|
||||
zle zle-line-init "$@"
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
|
||||
local __orca_prev_line_init_fn=""
|
||||
__orca_prompt_mark "$@"
|
||||
fi
|
||||
}
|
||||
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
|
||||
__orca_deferred_sched_init() {
|
||||
local __orca_prompt_status=$?
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
|
||||
builtin unset __orca_deferred_sched_armed
|
||||
builtin unfunction __orca_deferred_sched_init
|
||||
return $__orca_prompt_status
|
||||
}
|
||||
__orca_arm_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
|
||||
if (( ${+widgets[zle-line-init]} )); then
|
||||
zle -A zle-line-init __orca_saved_line_init
|
||||
fi
|
||||
zle -N zle-line-init __orca_deferred_line_init
|
||||
fi
|
||||
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
|
||||
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
|
||||
fi
|
||||
}
|
||||
__orca_deferred_init() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
@@ -54,8 +88,17 @@ __orca_deferred_init() {
|
||||
(( $+_orca_deferred_init_done )) && return 0
|
||||
builtin typeset -g _orca_deferred_init_done=1
|
||||
builtin typeset -g precmd_functions
|
||||
if (( ${+widgets[__orca_saved_line_init]} )); then
|
||||
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -A __orca_saved_line_init zle-line-init
|
||||
fi
|
||||
zle -D __orca_saved_line_init
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -D zle-line-init
|
||||
fi
|
||||
if __orca_has_feature markers; then
|
||||
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
|
||||
(( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
|
||||
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
|
||||
else
|
||||
precmd_functions=(${precmd_functions:#__orca_deferred_init})
|
||||
@@ -67,6 +110,12 @@ __orca_deferred_init() {
|
||||
printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2
|
||||
fi
|
||||
fi
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
|
||||
__orca_restore_agent_teams_path() {
|
||||
@@ -219,13 +268,25 @@ __orca_deferred_init() {
|
||||
# the permanent hook has not run yet and the first prompt would lose its mark.
|
||||
__orca_has_feature markers && __orca_osc133_precmd
|
||||
builtin unset _orca_shell_features _orca_histfile
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature
|
||||
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
|
||||
local __orca_widget __orca_line_init_bound=0
|
||||
for __orca_widget in "${(v)widgets[@]}"; do
|
||||
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
|
||||
__orca_line_init_bound=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
|
||||
}
|
||||
{
|
||||
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
|
||||
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
|
||||
} always {
|
||||
builtin unset _orca_user_zshenv
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
if (( ! $+_orca_deferred_init_done )); then
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
__orca_arm_deferred_line_init
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ fi
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
|
||||
[[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
# Why: OMP does not auto-load Orca's managed status extension; wrap only
|
||||
# interactive launch invocations so subcommands such as `omp config` keep
|
||||
# their normal argv shape.
|
||||
|
||||
@@ -31,6 +31,40 @@ builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}"
|
||||
builtin unset ORCA_HISTFILE
|
||||
__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) }
|
||||
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
|
||||
__orca_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) || return 0
|
||||
local __orca_direct_line_init=0
|
||||
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
|
||||
__orca_deferred_init
|
||||
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
|
||||
zle zle-line-init "$@"
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
|
||||
local __orca_prev_line_init_fn=""
|
||||
__orca_prompt_mark "$@"
|
||||
fi
|
||||
}
|
||||
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
|
||||
__orca_deferred_sched_init() {
|
||||
local __orca_prompt_status=$?
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
|
||||
builtin unset __orca_deferred_sched_armed
|
||||
builtin unfunction __orca_deferred_sched_init
|
||||
return $__orca_prompt_status
|
||||
}
|
||||
__orca_arm_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
|
||||
if (( ${+widgets[zle-line-init]} )); then
|
||||
zle -A zle-line-init __orca_saved_line_init
|
||||
fi
|
||||
zle -N zle-line-init __orca_deferred_line_init
|
||||
fi
|
||||
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
|
||||
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
|
||||
fi
|
||||
}
|
||||
__orca_deferred_init() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
@@ -40,7 +74,21 @@ __orca_deferred_init() {
|
||||
(( $+_orca_deferred_init_done )) && return 0
|
||||
builtin typeset -g _orca_deferred_init_done=1
|
||||
builtin typeset -g precmd_functions
|
||||
if (( ${+widgets[__orca_saved_line_init]} )); then
|
||||
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -A __orca_saved_line_init zle-line-init
|
||||
fi
|
||||
zle -D __orca_saved_line_init
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -D zle-line-init
|
||||
fi
|
||||
precmd_functions=(${precmd_functions:#__orca_deferred_init})
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: remote startup files can re-export user defaults after relay spawn.
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
@@ -170,13 +218,25 @@ __orca_deferred_init() {
|
||||
zle -N zle-line-init __orca_prompt_mark
|
||||
fi
|
||||
builtin unset _orca_shell_features _orca_histfile
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature
|
||||
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
|
||||
local __orca_widget __orca_line_init_bound=0
|
||||
for __orca_widget in "${(v)widgets[@]}"; do
|
||||
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
|
||||
__orca_line_init_bound=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
|
||||
}
|
||||
{
|
||||
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
|
||||
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
|
||||
} always {
|
||||
builtin unset _orca_user_zshenv
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
if (( ! $+_orca_deferred_init_done )); then
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
__orca_arm_deferred_line_init
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -147,8 +147,10 @@ describe('managed hook script refresh', () => {
|
||||
homedirMock.mockReturnValue(home)
|
||||
const previousGrokHome = process.env.GROK_HOME
|
||||
const previousKimiHome = process.env.KIMI_CODE_HOME
|
||||
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME
|
||||
delete process.env.GROK_HOME
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
try {
|
||||
await withPlatform('win32', () => {
|
||||
for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
|
||||
@@ -187,6 +189,11 @@ describe('managed hook script refresh', () => {
|
||||
} else {
|
||||
process.env.KIMI_CODE_HOME = previousKimiHome
|
||||
}
|
||||
if (previousXdgConfigHome === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = previousXdgConfigHome
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer } from './server'
|
||||
import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file'
|
||||
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
|
||||
import { PANE } from './server.test-fixtures'
|
||||
|
||||
describe('startup prompt control with status hooks disabled', () => {
|
||||
it.each([false, true])(
|
||||
'persists a pending terminal status at shutdown after starting with hooks %s',
|
||||
async (statusHooksEnabled) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-terminal-persist-'))
|
||||
const server = new AgentHookServer()
|
||||
try {
|
||||
await server.start({ userDataPath: dir, statusHooksEnabled })
|
||||
server.setStatusHooksEnabled(false)
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'folder-1',
|
||||
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
|
||||
})
|
||||
expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done')
|
||||
const statusPath = server.lastStatusPath
|
||||
if (!statusPath) {
|
||||
throw new Error('missing status persistence path')
|
||||
}
|
||||
server.stop()
|
||||
expect(existsSync(statusPath)).toBe(true)
|
||||
expect(JSON.parse(readFileSync(statusPath, 'utf8')).entries[PANE]).toMatchObject({
|
||||
paneKey: PANE,
|
||||
worktreeId: 'folder-1',
|
||||
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
|
||||
})
|
||||
} finally {
|
||||
server.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('enables and disables status without restarting the control listener', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-'))
|
||||
class IsolatedHookServer extends AgentHookServer {
|
||||
constructor() {
|
||||
super()
|
||||
this._setOpenCodeBinderDepsForTests({
|
||||
dbPath: () => join(dir, 'no-user-db'),
|
||||
listSessions: async () => [],
|
||||
listPanes: () => [],
|
||||
sweep: async () => []
|
||||
})
|
||||
}
|
||||
}
|
||||
const server = new IsolatedHookServer()
|
||||
try {
|
||||
await server.start({ userDataPath: dir, statusHooksEnabled: false })
|
||||
const endpoint = server.endpointFilePath
|
||||
if (!endpoint) {
|
||||
throw new Error('missing control endpoint')
|
||||
}
|
||||
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
|
||||
const post = (path: string) =>
|
||||
fetch(`http://127.0.0.1:${coords.port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-orca-agent-hook-token': coords.token },
|
||||
body: '{}'
|
||||
})
|
||||
expect((await post('/hook/opencode')).status).toBe(404)
|
||||
await server.start({ statusHooksEnabled: true })
|
||||
expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port)
|
||||
expect((await post('/hook/opencode')).status).toBe(204)
|
||||
server.setStatusHooksEnabled(false)
|
||||
expect(server.buildPtyEnv()).toEqual({})
|
||||
expect((await post('/hook/opencode')).status).toBe(404)
|
||||
await server.start()
|
||||
expect((await post('/hook/opencode')).status).toBe(404)
|
||||
server.setStartupPromptClaimListener(
|
||||
() => 'pending',
|
||||
() => {}
|
||||
)
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({
|
||||
allowed: false,
|
||||
pending: true
|
||||
})
|
||||
server.setStatusHooksEnabled(true)
|
||||
expect((await post('/hook/opencode')).status).toBe(204)
|
||||
expect(server.endpointFilePath).toBe(endpoint)
|
||||
expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords)
|
||||
} finally {
|
||||
server.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-'))
|
||||
const server = new AgentHookServer()
|
||||
try {
|
||||
await server.start({ userDataPath: dir, statusHooksEnabled: false })
|
||||
expect(server.buildPtyEnv()).toEqual({})
|
||||
const statusPath = server.lastStatusPath
|
||||
if (!statusPath) {
|
||||
throw new Error('missing status persistence path')
|
||||
}
|
||||
writeFileSync(statusPath, 'existing status must survive control-only shutdown')
|
||||
const endpoint = server.endpointFilePath
|
||||
if (!endpoint) {
|
||||
throw new Error('missing control endpoint')
|
||||
}
|
||||
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
|
||||
const post = (path: string, body: string, token = coords.token) =>
|
||||
fetch(`http://127.0.0.1:${coords.port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token },
|
||||
body
|
||||
})
|
||||
expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403)
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
|
||||
allowed: false
|
||||
})
|
||||
const clear = vi.fn()
|
||||
const claim = vi.fn(() => true)
|
||||
server.setStartupPromptClaimListener(claim, clear)
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
|
||||
allowed: true
|
||||
})
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({
|
||||
allowed: false
|
||||
})
|
||||
expect(claim).toHaveBeenCalledTimes(1)
|
||||
expect((await post('/hook/opencode', '{}')).status).toBe(404)
|
||||
expect((await post('/statusline/claude', '{}')).status).toBe(404)
|
||||
server.stop()
|
||||
expect(clear).toHaveBeenCalledTimes(1)
|
||||
expect(readFileSync(statusPath, 'utf8')).toBe(
|
||||
'existing status must survive control-only shutdown'
|
||||
)
|
||||
} finally {
|
||||
server.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,11 @@
|
||||
// short of its own Content-Length. The listener fails open on every request error, so the only
|
||||
// way this stays diagnosable is if the truncation is classified before it is swallowed.
|
||||
import { connect } from 'node:net'
|
||||
import { ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference'
|
||||
import { AgentHookServer } from './server'
|
||||
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
|
||||
|
||||
async function postTruncatedHook(
|
||||
port: number,
|
||||
@@ -40,11 +42,15 @@ async function postTruncatedHook(
|
||||
}
|
||||
|
||||
/** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */
|
||||
async function postStalledHook(port: number, token: string): Promise<void> {
|
||||
async function postStalledHook(
|
||||
port: number,
|
||||
token: string,
|
||||
pathname = '/hook/claude'
|
||||
): Promise<void> {
|
||||
const socket = connect({ port, host: '127.0.0.1' })
|
||||
await new Promise<void>((resolve) => socket.on('connect', () => resolve()))
|
||||
socket.write(
|
||||
`POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
|
||||
`POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
|
||||
)
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.on('close', () => resolve())
|
||||
@@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => {
|
||||
expect(reports).toHaveLength(1)
|
||||
}, 20_000)
|
||||
|
||||
it('classifies an interrupted startup claim as retryable without consuming it', async () => {
|
||||
const { server, port, token, reports } = await startServer()
|
||||
const claim = vi.fn(() => true)
|
||||
server.setStartupPromptClaimListener(claim, () => {})
|
||||
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
|
||||
try {
|
||||
await postTruncatedHook(port, token, {
|
||||
pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH,
|
||||
sentBytes: '{"nonce":',
|
||||
announcedLength: 1000
|
||||
})
|
||||
// The reset peer cannot receive this response; observe the real handler's classification.
|
||||
expect(writeHead.mock.calls).toEqual([[503]])
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(reports).toEqual([])
|
||||
} finally {
|
||||
writeHead.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => {
|
||||
const { server, port, token, reports } = await startServer()
|
||||
const claim = vi.fn(() => true)
|
||||
server.setStartupPromptClaimListener(claim, () => {})
|
||||
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
|
||||
try {
|
||||
await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH)
|
||||
expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]])
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(reports).toEqual([])
|
||||
} finally {
|
||||
writeHead.mockRestore()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('never reports for POSTs that deliver their whole body', async () => {
|
||||
const { port, token, reports } = await startServer()
|
||||
|
||||
|
||||
@@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod
|
||||
import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing'
|
||||
import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits'
|
||||
import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference'
|
||||
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
|
||||
import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state'
|
||||
import { trackEmptyPaneKeyHook } from './server-transport-rules'
|
||||
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
|
||||
import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle'
|
||||
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt'
|
||||
|
||||
export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv {
|
||||
export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle {
|
||||
/** Start the loopback listener after hydration and spool replay have settled. */
|
||||
async start(options?: {
|
||||
env?: string
|
||||
userDataPath?: string
|
||||
endpointNamespace?: string
|
||||
statusHooksEnabled?: boolean
|
||||
}): Promise<void> {
|
||||
if (this.server) {
|
||||
if (options?.statusHooksEnabled !== undefined) {
|
||||
this.setStatusHooksEnabled(options.statusHooksEnabled)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.statusHooksEnabled = options?.statusHooksEnabled !== false
|
||||
|
||||
if (options?.env) {
|
||||
this.env = options.env
|
||||
@@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
this.token = randomUUID()
|
||||
this.endpointFileWritten = false
|
||||
this.lastWrittenJson = null
|
||||
if (!this.ownerStateInitialized) {
|
||||
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
|
||||
if (this.lastStatusFilePath) {
|
||||
this.hydrateLastStatusFromDisk()
|
||||
}
|
||||
this.captureHydratedAuthorityCommitments()
|
||||
// Drain before binding the listener so replay cannot race a live hook during startup.
|
||||
if (this.endpointDir) {
|
||||
const replayedPaneKeys = new Set<string>()
|
||||
drainAgentHookSpool({
|
||||
endpointDir: this.endpointDir,
|
||||
getPersistedLaunchTokenHash: (paneKey) =>
|
||||
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
|
||||
ingest: (record: SpoolRecord) => {
|
||||
this.ingestSpoolRecord(record)
|
||||
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
|
||||
}
|
||||
})
|
||||
// Why: the owner may have died while Orca was down; check each replayed pane once.
|
||||
for (const paneKey of replayedPaneKeys) {
|
||||
void this.checkAgentPresence(paneKey)
|
||||
}
|
||||
}
|
||||
this.ownerStateInitialized = true
|
||||
if (this.statusHooksEnabled) {
|
||||
this.initializeStatusHookOwner()
|
||||
}
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
if (req.method !== 'POST') {
|
||||
@@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
||||
try {
|
||||
const body = await readRequestBody(req)
|
||||
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
const claim = this.onStartupPromptClaim?.(body)
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
allowed: claim === true,
|
||||
...(claim === 'pending' ? { pending: true } : {})
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.statusHooksEnabled) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
|
||||
const statusLineEvent = parseClaudeStatusLineBody(body)
|
||||
if (statusLineEvent) {
|
||||
@@ -152,6 +151,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} catch (error) {
|
||||
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
|
||||
if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) {
|
||||
res.writeHead(503)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end('{"allowed":false}')
|
||||
return
|
||||
}
|
||||
// Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut
|
||||
// by something on the loopback path, not by a bad payload. Fail open as before, but count it —
|
||||
// this is the one failure mode that silently stops status for every runtime at once.
|
||||
@@ -192,7 +201,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
this.rollbackTransportStart()
|
||||
throw error
|
||||
}
|
||||
this.startOpenCodeBinderLoop()
|
||||
if (this.statusHooksEnabled) {
|
||||
this.startOpenCodeBinderLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private rollbackTransportStart(): void {
|
||||
@@ -204,14 +215,19 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
|
||||
this.flushStatusPersistSync()
|
||||
// Terminal status may still have a pending write while hook ingress is disabled.
|
||||
if (this.statusHooksEnabled || this.statusPersistTimer) {
|
||||
this.flushStatusPersistSync()
|
||||
}
|
||||
this.stopOpenCodeBinderLoop()
|
||||
this.stopTmuxStatus()
|
||||
this.rollbackTransportStart()
|
||||
this.env = 'production'
|
||||
this.onAgentStatus = null
|
||||
this.onClaudeStatusLine = null
|
||||
this.clearStartupPromptClaims?.()
|
||||
this.clearStartupPromptClaims = null
|
||||
this.onStartupPromptClaim = null
|
||||
this.onPaneStatusCleared = null
|
||||
this.onTransportInterference = null
|
||||
this.transportInterference.reset()
|
||||
|
||||
@@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row'
|
||||
const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER
|
||||
|
||||
export abstract class AgentHookServerListeners extends AgentHookServerState {
|
||||
setStartupPromptClaimListener(
|
||||
listener: (body: unknown) => boolean | 'pending',
|
||||
clear: () => void
|
||||
): void {
|
||||
this.onStartupPromptClaim = listener
|
||||
this.clearStartupPromptClaims = clear
|
||||
}
|
||||
|
||||
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
|
||||
this.onAgentStatus?.(enriched)
|
||||
for (const listener of this.enrichedStatusListeners) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote'
|
||||
|
||||
export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote {
|
||||
buildPtyEnv(): Record<string, string> {
|
||||
if (this.port <= 0 || !this.token) {
|
||||
if (!this.statusHooksEnabled || this.port <= 0 || !this.token) {
|
||||
return {}
|
||||
}
|
||||
const env: Record<string, string> = {
|
||||
|
||||
@@ -89,6 +89,9 @@ export abstract class AgentHookServerState {
|
||||
protected env = 'production'
|
||||
protected onAgentStatus: ServerAgentStatusListener = null
|
||||
protected onClaudeStatusLine: ServerStatusLineListener = null
|
||||
protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null
|
||||
protected clearStartupPromptClaims: (() => void) | null = null
|
||||
protected statusHooksEnabled = true
|
||||
protected onPaneStatusCleared: PaneStatusClearListener | null = null
|
||||
protected paneStatusClearListeners = new Set<PaneStatusClearListener>()
|
||||
protected statusDropListeners = new Set<StatusDropListener>()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
|
||||
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
|
||||
|
||||
export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv {
|
||||
setStatusHooksEnabled(enabled: boolean): void {
|
||||
if (enabled === this.statusHooksEnabled) {
|
||||
return
|
||||
}
|
||||
if (!enabled) {
|
||||
this.flushStatusPersistSync()
|
||||
this.stopOpenCodeBinderLoop()
|
||||
for (const timer of this.assistantMessageRetryTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.assistantMessageRetryTimers.clear()
|
||||
this.clearAllTranscriptPolls()
|
||||
}
|
||||
this.statusHooksEnabled = enabled
|
||||
if (enabled && this.server) {
|
||||
this.initializeStatusHookOwner()
|
||||
this.startOpenCodeBinderLoop()
|
||||
}
|
||||
}
|
||||
|
||||
protected initializeStatusHookOwner(): void {
|
||||
if (!this.ownerStateInitialized) {
|
||||
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
|
||||
if (this.lastStatusFilePath) {
|
||||
this.hydrateLastStatusFromDisk()
|
||||
}
|
||||
this.captureHydratedAuthorityCommitments()
|
||||
// Drain before binding the listener so replay cannot race a live hook during startup.
|
||||
if (this.endpointDir) {
|
||||
const replayedPaneKeys = new Set<string>()
|
||||
drainAgentHookSpool({
|
||||
endpointDir: this.endpointDir,
|
||||
getPersistedLaunchTokenHash: (paneKey) =>
|
||||
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
|
||||
ingest: (record: SpoolRecord) => {
|
||||
this.ingestSpoolRecord(record)
|
||||
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
|
||||
}
|
||||
})
|
||||
// Why: the owner may have died while Orca was down; check each replayed pane once.
|
||||
for (const paneKey of replayedPaneKeys) {
|
||||
void this.checkAgentPresence(paneKey)
|
||||
}
|
||||
}
|
||||
this.ownerStateInitialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -122,6 +123,44 @@ describe('markCopilotFolderTrusted', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a token-bearing config.json owner-only', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
|
||||
const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
|
||||
// Why: a restrictive runner umask would mask a dropped mode.
|
||||
const originalUmask = process.umask(0o022)
|
||||
try {
|
||||
mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true })
|
||||
writeFileSync(configPath, JSON.stringify({ copilotTokens: { a: 'secret' } }), {
|
||||
mode: 0o600
|
||||
})
|
||||
markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
|
||||
expect(statSync(configPath).mode & 0o777).toBe(0o600)
|
||||
expect(JSON.parse(readFileSync(configPath, 'utf-8')).copilotTokens).toEqual({ a: 'secret' })
|
||||
} finally {
|
||||
process.umask(originalUmask)
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a missing config.json owner-only', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
|
||||
const originalUmask = process.umask(0o022)
|
||||
try {
|
||||
markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
|
||||
const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
|
||||
expect(statSync(configPath).mode & 0o777).toBe(0o600)
|
||||
} finally {
|
||||
process.umask(originalUmask)
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves existing config keys and dedups already-trusted folders', () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
|
||||
const realpath = realpathSync(workspace)
|
||||
|
||||
@@ -97,7 +97,8 @@ export function markCopilotFolderTrusted(workspacePath: string, home: string): v
|
||||
if (!existsSync(configDir)) {
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
}
|
||||
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
// Why: config.json can hold copilotTokens, so it must stay owner-only (also on shared SSH hosts).
|
||||
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,9 +53,12 @@ test.each(['runtime', 'relay'] as const)(
|
||||
},
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(service.search).not.toHaveBeenCalled()
|
||||
expect(service.reconcile).not.toHaveBeenCalled()
|
||||
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
|
||||
expect(service.search).toHaveBeenCalledExactlyOnceWith(
|
||||
{ query: 'proof', limit: 20, freshness: 'wait-until-current' },
|
||||
{ kind: 'resolved', paths: [''] }
|
||||
)
|
||||
expect(service.reconcile).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -84,7 +87,7 @@ test.each(['qoder', 'jcode'] as const)(
|
||||
)
|
||||
|
||||
test.each(['runtime', 'relay'] as const)(
|
||||
'does not query or reconcile an unsupported-only filter on %s',
|
||||
'keeps an unsupported-only filter narrow through normal service checks on %s',
|
||||
async (transport) => {
|
||||
const request = {
|
||||
query: 'proof',
|
||||
@@ -105,14 +108,25 @@ test.each(['runtime', 'relay'] as const)(
|
||||
{ ...request, filters: { agents: [...request.filters.agents] } },
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(service.search).not.toHaveBeenCalled()
|
||||
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
|
||||
expect(service.search).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
query: 'proof',
|
||||
limit: 20,
|
||||
filters: { agents: ['jcode'] },
|
||||
freshness: 'wait-until-current'
|
||||
},
|
||||
{ kind: 'resolved', paths: [''] }
|
||||
)
|
||||
expect(service.status).not.toHaveBeenCalled()
|
||||
expect(service.reconcile).not.toHaveBeenCalled()
|
||||
expect(service.reconcile).toHaveBeenCalledTimes(1)
|
||||
for (const reason of ['disabled', 'not-ready'] as const) {
|
||||
service.search.mockResolvedValue({ kind: 'unavailable', reason })
|
||||
expect(
|
||||
await searchSessionService({ query: 'proof', filters: { agents: ['codex'] } }, transport)
|
||||
await searchSessionService(
|
||||
{ query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } },
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@ describe('session search service registry', () => {
|
||||
{ query: 'proof', supportedAgents: ['claude'], filters: { agents: [agent] } },
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(service.search).toHaveBeenCalledTimes(1)
|
||||
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
|
||||
expect(service.search).toHaveBeenCalledTimes(2)
|
||||
expect(service.reconcile).not.toHaveBeenCalled()
|
||||
const client = createSessionSearchClient(
|
||||
(method, request) =>
|
||||
@@ -114,6 +114,24 @@ describe('session search service registry', () => {
|
||||
)
|
||||
expect((await sessionSearchServiceStatus({}, 'ipc')).supportedAgents).toEqual(AI_VAULT_AGENTS)
|
||||
})
|
||||
it.each(['disabled', 'not-ready'] as const)(
|
||||
'preserves %s precedence for empty negotiated host intersections',
|
||||
async (reason) => {
|
||||
const service = fakeSearchService()
|
||||
service.search.mockResolvedValue({ kind: 'unavailable', reason })
|
||||
setSessionSearchService(service)
|
||||
expect(
|
||||
await searchSessionService(
|
||||
{ query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } },
|
||||
'runtime'
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason })
|
||||
expect(service.search).toHaveBeenCalledExactlyOnceWith(
|
||||
{ query: 'proof', limit: 20, filters: { agents: ['jcode'] } },
|
||||
{ kind: 'resolved', paths: [''] }
|
||||
)
|
||||
}
|
||||
)
|
||||
it('waits for reconcile before search, and clears its timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const service = fakeSearchService()
|
||||
|
||||
@@ -52,11 +52,8 @@ export async function searchSessionService(
|
||||
supportsJcodeHistory
|
||||
}
|
||||
)
|
||||
if (compatibleAgents.length === 0) {
|
||||
return { kind: 'unavailable', reason: 'unsupported-agent' }
|
||||
}
|
||||
const compatibleRequest =
|
||||
compatibleAgents.length === agents.length
|
||||
compatibleAgents.length === 0 || compatibleAgents.length === agents.length
|
||||
? request
|
||||
: {
|
||||
...request,
|
||||
@@ -68,12 +65,16 @@ export async function searchSessionService(
|
||||
const hostScope = within
|
||||
? resolveSessionSearchScope(within, sessionSearchScopeCatalog())
|
||||
: undefined
|
||||
const retrievalScope =
|
||||
compatibleAgents.length === 0 && hostScope?.kind !== 'unknown'
|
||||
? { kind: 'resolved' as const, paths: [''] }
|
||||
: hostScope
|
||||
const freshness =
|
||||
request.freshness === 'wait-until-current'
|
||||
? await reconcileWithin(current, freshnessTimeoutMs)
|
||||
: false
|
||||
const result = AiVaultSearchResponseSchema.parse(
|
||||
await current.search(compatibleRequest, hostScope)
|
||||
await current.search(compatibleRequest, retrievalScope)
|
||||
)
|
||||
if (result.kind !== 'results') {
|
||||
return result
|
||||
@@ -81,6 +82,7 @@ export async function searchSessionService(
|
||||
const { debug, ...fields } = result
|
||||
return {
|
||||
...fields,
|
||||
...(compatibleAgents.length === 0 ? { page: { cursor: null, hasMore: false } } : {}),
|
||||
hits: result.hits
|
||||
.filter((hit) => compatibleAgents.includes(hit.agent))
|
||||
.map((hit) => redactForTransport(hit, transport)),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readStreamedSessionDocument } from './session-document-stream'
|
||||
|
||||
async function* bytes(content: string, chunkSize = 7): AsyncGenerator<Buffer> {
|
||||
const buffer = Buffer.from(content)
|
||||
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
|
||||
yield buffer.subarray(offset, offset + chunkSize)
|
||||
}
|
||||
}
|
||||
|
||||
function read(
|
||||
content: string,
|
||||
overrides: Partial<Parameters<typeof readStreamedSessionDocument<unknown[]>>[0]> = {}
|
||||
) {
|
||||
return readStreamedSessionDocument({
|
||||
bytes: bytes(content),
|
||||
arrayKey: 'messages',
|
||||
fields: ['id'],
|
||||
objectFields: { agent: ['model'] },
|
||||
create: (): unknown[] => [],
|
||||
consume: (state, value) => {
|
||||
state.push(value)
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('streamed session document contracts', () => {
|
||||
it.each(['[]', 'null', 'false', '0', '"not an array"', '{}'])(
|
||||
'a later messages value %s clears an earlier fold',
|
||||
async (last) => {
|
||||
expect(await read(`{"messages":[1,2],"messages":${last}}`)).toEqual({ record: {}, state: [] })
|
||||
}
|
||||
)
|
||||
|
||||
it('replaces a failed earlier array and continues with its successor', async () => {
|
||||
const consume = (state: unknown[], value: unknown): void => {
|
||||
if (value === 'bad') {
|
||||
throw new Error('discarded failure')
|
||||
}
|
||||
state.push(value)
|
||||
}
|
||||
expect(await read('{"messages":["bad",1],"messages":[2,3]}', { consume })).toEqual({
|
||||
record: {},
|
||||
state: [2, 3]
|
||||
})
|
||||
expect(await read('{"messages":["bad"],"messages":[]}', { consume })).toEqual({
|
||||
record: {},
|
||||
state: []
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the first consumer failure unless a later array replaces it', async () => {
|
||||
const failure = new Error('consumer failed')
|
||||
let calls = 0
|
||||
await expect(
|
||||
read('{"messages":[1,2]}', {
|
||||
consume: () => {
|
||||
calls++
|
||||
throw failure
|
||||
}
|
||||
})
|
||||
).rejects.toBe(failure)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('gives malformed trailing JSON precedence over a consumer failure', async () => {
|
||||
await expect(
|
||||
read('{"messages":[1]} trailing', {
|
||||
consume: () => {
|
||||
throw new Error('consumer')
|
||||
}
|
||||
})
|
||||
).rejects.toBeInstanceOf(SyntaxError)
|
||||
})
|
||||
|
||||
it('keeps projected object resets and full-field overlap precedence', async () => {
|
||||
expect(await read('{"agent":{"model":"old"},"agent":null}')).toEqual({
|
||||
record: { agent: {} },
|
||||
state: []
|
||||
})
|
||||
expect(await read('{"agent":{"model":"m","extra":[1,2]}}', { fields: ['agent'] })).toEqual({
|
||||
record: { agent: { model: 'm', extra: [1, 2] } },
|
||||
state: []
|
||||
})
|
||||
expect(await read('{"messages":[1,2]}', { objectFields: { messages: ['model'] } })).toEqual({
|
||||
record: { messages: {} },
|
||||
state: [1, 2]
|
||||
})
|
||||
expect(
|
||||
await read('{"messages":{"model":"m","ignored":1}}', {
|
||||
objectFields: { messages: ['model'] }
|
||||
})
|
||||
).toEqual({ record: { messages: { model: 'm' } }, state: [] })
|
||||
})
|
||||
|
||||
it('preserves selected prototype keys as own properties', async () => {
|
||||
const content =
|
||||
'{"id":{"__proto__":{"polluted":true}},"agent":{"model":{"constructor":"value","__proto__":{"x":1}}}}'
|
||||
const parsed = await read(content)
|
||||
expect(parsed?.record).toEqual(JSON.parse(content))
|
||||
expect(Object.getPrototypeOf(parsed?.record)).toBeNull()
|
||||
expect(Object.prototype).not.toHaveProperty('polluted')
|
||||
})
|
||||
|
||||
it.each(['', ' ', '{', '{"messages":[1,]}', '{"messages":[]}{"messages":[]}'])(
|
||||
'rejects malformed input %j',
|
||||
async (content) => {
|
||||
await expect(read(content)).rejects.toBeInstanceOf(SyntaxError)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['null', '[]', '123', '"text"'])(
|
||||
'does not publish a nonobject document %s',
|
||||
async (content) => {
|
||||
expect(await read(content)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('validates discarded subtrees and closes their source on malformed input', async () => {
|
||||
let closed = false
|
||||
async function* malformed() {
|
||||
try {
|
||||
yield Buffer.from('{"ignored":[{"deep":1},]}')
|
||||
throw new Error('must not request another chunk')
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
await expect(read('', { bytes: malformed() })).rejects.toBeInstanceOf(SyntaxError)
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves source failure and closes the source even after a consumer failure', async () => {
|
||||
const failure = new Error('disk failure')
|
||||
let closed = false
|
||||
async function* failing() {
|
||||
try {
|
||||
yield Buffer.from('{"messages":[1]')
|
||||
throw failure
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
await expect(
|
||||
read('', {
|
||||
bytes: failing(),
|
||||
consume: () => {
|
||||
throw new Error('consumer')
|
||||
}
|
||||
})
|
||||
).rejects.toBe(failure)
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves escaped astral text across large and byte-sized chunks', async () => {
|
||||
const value = `${'x'.repeat(65530)}日本語😀\\literal`
|
||||
const content = JSON.stringify({ messages: [value, '\ud800', '\udc00'] })
|
||||
for (const size of [1, 65536, content.length * 4]) {
|
||||
expect(await read(content, { bytes: bytes(content, size) })).toEqual({
|
||||
record: {},
|
||||
state: [value, '\ud800', '\udc00']
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json'
|
||||
import { FlexAssembler, arrayRule, objectRule } from 'stream-json/core/utils/flex-assembler.js'
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { createJsonTokenReader } from '../../shared/json-token-reader'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
|
||||
/** Fold one root array while retaining only the root fields the agent parser uses. */
|
||||
@@ -13,92 +14,108 @@ export async function readStreamedSessionDocument<T>(args: {
|
||||
consume: (state: T, value: unknown) => void
|
||||
signal?: AbortSignal
|
||||
}): Promise<{ record: Record<string, unknown>; state: T } | null> {
|
||||
const parser = new JSONParser({
|
||||
paths: [
|
||||
...args.fields.map((field) => `$.${field}`),
|
||||
...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) =>
|
||||
fields.map((field) => `$.${root}.${field}`)
|
||||
),
|
||||
...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : [])
|
||||
],
|
||||
keepStack: false,
|
||||
stringBufferSize: 64 * 1024
|
||||
})
|
||||
const record: Record<string, unknown> = Object.create(null)
|
||||
const fields = new Set(args.fields)
|
||||
let depth = 0
|
||||
let expectingRootKey = false
|
||||
parser.onToken = ({ token, value }) => {
|
||||
if (depth === 1 && expectingRootKey && token === TokenType.STRING) {
|
||||
if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) {
|
||||
record[value] = Object.create(null)
|
||||
}
|
||||
expectingRootKey = false
|
||||
}
|
||||
if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) {
|
||||
if (depth === 0 && token === TokenType.LEFT_BRACE) {
|
||||
expectingRootKey = true
|
||||
}
|
||||
depth++
|
||||
} else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) {
|
||||
depth--
|
||||
} else if (token === TokenType.COMMA && depth === 1) {
|
||||
expectingRootKey = true
|
||||
}
|
||||
}
|
||||
const objectFields = new Map(
|
||||
Object.entries(args.objectFields ?? {}).map(([root, keys]) => [root, new Set(keys)])
|
||||
)
|
||||
const foldedArray = Symbol('folded session array')
|
||||
let state = args.create()
|
||||
let currentArray: unknown = null
|
||||
let consumeFailure: { error: unknown } | undefined
|
||||
let inFoldedArray = false
|
||||
const reset = (): void => {
|
||||
state = args.create()
|
||||
consumeFailure = undefined
|
||||
}
|
||||
const retain = (path: (string | number)[]): boolean => {
|
||||
const [root, child] = path
|
||||
return (
|
||||
typeof root === 'string' &&
|
||||
((root !== args.arrayKey && fields.has(root)) ||
|
||||
(inFoldedArray && root === args.arrayKey && path.length >= 2) ||
|
||||
(typeof child === 'string' && objectFields.get(root)?.has(child) === true))
|
||||
)
|
||||
}
|
||||
// Every discarded container needs a rule; dropping only its parent still builds large children.
|
||||
const discard = {
|
||||
filter: (path: (string | number)[]) => !retain(path),
|
||||
create: () => null,
|
||||
add: () => {}
|
||||
}
|
||||
const assembler = new FlexAssembler({
|
||||
maxDepth: Infinity,
|
||||
objectRules: [
|
||||
objectRule<Record<string, unknown>>({
|
||||
filter: (path) => path.length === 0,
|
||||
create: () => record,
|
||||
add: (target, key, value) => {
|
||||
if (key === args.arrayKey) {
|
||||
if (value !== foldedArray) {
|
||||
reset()
|
||||
}
|
||||
} else if (fields.has(key)) {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}),
|
||||
objectRule<Record<string, unknown>>({
|
||||
filter: (path) =>
|
||||
path.length === 1 &&
|
||||
typeof path[0] === 'string' &&
|
||||
objectFields.has(path[0]) &&
|
||||
(!fields.has(path[0]) || path[0] === args.arrayKey),
|
||||
create: (path) => {
|
||||
const projected: Record<string, unknown> = Object.create(null)
|
||||
record[String(path[0])] = projected
|
||||
return projected
|
||||
},
|
||||
add: (target, key, value) => {
|
||||
const root = assembler.path[0]
|
||||
if (typeof root === 'string' && objectFields.get(root)?.has(key)) {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}),
|
||||
discard
|
||||
],
|
||||
arrayRules: [
|
||||
arrayRule<null>({
|
||||
filter: (path) => Boolean(args.arrayKey) && path.length === 1 && path[0] === args.arrayKey,
|
||||
create: () => {
|
||||
reset()
|
||||
inFoldedArray = true
|
||||
return null
|
||||
},
|
||||
add: (_target, value) => {
|
||||
if (!consumeFailure) {
|
||||
try {
|
||||
args.consume(state, value)
|
||||
} catch (error) {
|
||||
consumeFailure = { error }
|
||||
}
|
||||
}
|
||||
},
|
||||
finalize: () => {
|
||||
inFoldedArray = false
|
||||
return foldedArray
|
||||
}
|
||||
}),
|
||||
discard
|
||||
]
|
||||
})
|
||||
const parser = createJsonTokenReader((token) => {
|
||||
if (
|
||||
token.name === 'keyValue' &&
|
||||
assembler.depth === 1 &&
|
||||
!assembler.isArray &&
|
||||
objectFields.has(token.value)
|
||||
) {
|
||||
record[token.value] = Object.create(null)
|
||||
}
|
||||
assembler.consume(token)
|
||||
})
|
||||
const decoder = new StringDecoder('utf8')
|
||||
let objectRoot: boolean | undefined
|
||||
parser.onValue = ({ key, value, parent, stack }) => {
|
||||
if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) {
|
||||
if (parent !== currentArray) {
|
||||
state = args.create()
|
||||
consumeFailure = undefined
|
||||
currentArray = parent
|
||||
}
|
||||
if (!consumeFailure) {
|
||||
try {
|
||||
args.consume(state, value)
|
||||
} catch (error) {
|
||||
consumeFailure = { error }
|
||||
}
|
||||
}
|
||||
// The parser's array cursor is independent of retained array slots.
|
||||
parent.pop()
|
||||
} else if (
|
||||
stack.length === 2 &&
|
||||
typeof stack[1].key === 'string' &&
|
||||
typeof key === 'string' &&
|
||||
parent &&
|
||||
!Array.isArray(parent)
|
||||
) {
|
||||
const root = stack[1].key
|
||||
const projected = record[root]
|
||||
if (
|
||||
Object.hasOwn(args.objectFields ?? {}, root) &&
|
||||
args.objectFields?.[root]?.includes(key) &&
|
||||
projected &&
|
||||
typeof projected === 'object'
|
||||
) {
|
||||
Reflect.set(projected, key, value)
|
||||
}
|
||||
} else if (stack.length === 1 && typeof key === 'string') {
|
||||
if (key === args.arrayKey) {
|
||||
if (value !== currentArray || !Array.isArray(value)) {
|
||||
state = args.create()
|
||||
consumeFailure = undefined
|
||||
}
|
||||
currentArray = null
|
||||
} else if (fields.has(key)) {
|
||||
record[key] = value
|
||||
}
|
||||
if (parent && typeof parent === 'object') {
|
||||
Reflect.deleteProperty(parent, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
for await (const chunk of args.bytes) {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
if (objectRoot === undefined) {
|
||||
@@ -107,37 +124,17 @@ export async function readStreamedSessionDocument<T>(args: {
|
||||
objectRoot = first === 123
|
||||
}
|
||||
}
|
||||
parseJson(() => parser.write(decoder.write(chunk)))
|
||||
parser.write(decoder.write(chunk))
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
const tail = decoder.end()
|
||||
if (tail) {
|
||||
parseJson(() => parser.write(tail))
|
||||
}
|
||||
if (objectRoot === undefined) {
|
||||
throw new SyntaxError('Unexpected end of JSON input')
|
||||
}
|
||||
if (!parser.isEnded) {
|
||||
parseJson(() => parser.end(), true)
|
||||
parser.write(tail)
|
||||
}
|
||||
parser.end()
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
if (consumeFailure) {
|
||||
throw consumeFailure.error
|
||||
}
|
||||
return objectRoot ? { record, state } : null
|
||||
}
|
||||
|
||||
function parseJson(run: () => void, ending = false): void {
|
||||
try {
|
||||
run()
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof TokenizerError ||
|
||||
error instanceof TokenParserError ||
|
||||
(ending && error instanceof Error)
|
||||
) {
|
||||
throw new SyntaxError(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +73,13 @@ describe('OpenCode reads through the production shared worker', () => {
|
||||
throw new Error('Expected the bounded native page')
|
||||
}
|
||||
expect(page.items.map((item) => item.rowid)).toEqual([2, 3])
|
||||
expect(page.items[0]?.message.role).toBe('system')
|
||||
expect(page.items[0]?.message.blocks).toEqual([
|
||||
{ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }
|
||||
{
|
||||
type: 'text',
|
||||
text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'],
|
||||
presentation: 'history-item-too-large'
|
||||
}
|
||||
])
|
||||
const repeat = await client.readNativeChat(args)
|
||||
expect(repeat).toEqual(page)
|
||||
|
||||
@@ -67,11 +67,11 @@ describe('protected Antigravity account snapshots', () => {
|
||||
const service = new AntigravityAccountService(store, h.backend)
|
||||
h.setNative(paddedCredential('new-account', 60000))
|
||||
await expect(service.addCurrentAccount()).rejects.toThrow('could not be saved')
|
||||
expect(readFileSync(path)).toEqual(before)
|
||||
expect(readFileSync(path).equals(before)).toBe(true)
|
||||
expect(store.read().accounts).toHaveLength(52)
|
||||
h.setNative(paddedCredential('large-51', 65000))
|
||||
await expect(service.listAccounts()).rejects.toThrow('could not be saved')
|
||||
expect(readFileSync(path)).toEqual(before)
|
||||
expect(readFileSync(path).equals(before)).toBe(true)
|
||||
expect(store.read().accounts).toHaveLength(52)
|
||||
})
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('protected Antigravity account snapshots', () => {
|
||||
'Protected secret storage'
|
||||
)
|
||||
expect(() => store.read()).toThrow('Protected secret storage')
|
||||
expect(readFileSync(path)).toEqual(before)
|
||||
expect(readFileSync(path).equals(before)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -51,6 +51,28 @@ const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
|
||||
token: 'annotationviewporttoken'
|
||||
}
|
||||
|
||||
const MARKER_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
|
||||
...BRIDGE_OPTIONS,
|
||||
markers: [
|
||||
{
|
||||
id: 'historical-document-marker',
|
||||
index: 2,
|
||||
isFixed: false,
|
||||
rectPage: { x: 80, y: 120, width: 100, height: 40 },
|
||||
rectViewport: { x: 80, y: 120, width: 100, height: 40 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function deferGuestInjection(guest: Record<string, unknown>): () => void {
|
||||
let release = (): void => {}
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
guest.executeJavaScriptInIsolatedWorld = vi.fn(() => gate)
|
||||
return release
|
||||
}
|
||||
|
||||
function registerPage(pageId: string, guest: Record<string, unknown>): void {
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
@@ -141,6 +163,161 @@ describe('browserManager.setAnnotationViewportBridge', () => {
|
||||
expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'skips retired queued markers after invalidation (replacement guest: %s)',
|
||||
async (replaceGuest) => {
|
||||
const { guest: firstGuest } = makeGuest(5201)
|
||||
const release = deferGuestInjection(firstGuest)
|
||||
registerPage('tab-retired-markers', firstGuest)
|
||||
const resolveGuest = resolveFromRegistry('tab-retired-markers')
|
||||
const inFlight = browserManager.setAnnotationViewportBridge(
|
||||
'tab-retired-markers',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
await flushViewportOps()
|
||||
const retired = browserManager.setAnnotationViewportBridge(
|
||||
'tab-retired-markers',
|
||||
MARKER_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
const { guest: replacementGuest } = makeGuest(5202)
|
||||
if (replaceGuest) {
|
||||
registerPage('tab-retired-markers', replacementGuest)
|
||||
}
|
||||
const cleared = browserManager.setAnnotationViewportBridge(
|
||||
'tab-retired-markers',
|
||||
{ ...BRIDGE_OPTIONS, enabled: false },
|
||||
resolveGuest
|
||||
)
|
||||
|
||||
release()
|
||||
await expect(inFlight).resolves.toBe(true)
|
||||
await expect(retired).resolves.toBe(false)
|
||||
await expect(cleared).resolves.toBe(true)
|
||||
const currentGuest = replaceGuest ? replacementGuest : firstGuest
|
||||
expect(currentGuest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
[expect.objectContaining({ code: expect.stringContaining('historical-document-marker') })],
|
||||
false
|
||||
)
|
||||
expect(currentGuest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
|
||||
expect.any(Number),
|
||||
[expect.objectContaining({ code: expect.stringContaining('const enabled = false;') })],
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an in-flight injection and applies the latest fresh markers and token', async () => {
|
||||
const { guest } = makeGuest(5203)
|
||||
const release = deferGuestInjection(guest)
|
||||
const resolveGuest = resolveFromRegistry('tab-fresh-markers')
|
||||
registerPage('tab-fresh-markers', guest)
|
||||
const inFlight = browserManager.setAnnotationViewportBridge(
|
||||
'tab-fresh-markers',
|
||||
MARKER_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
await flushViewportOps()
|
||||
const cleared = browserManager.setAnnotationViewportBridge(
|
||||
'tab-fresh-markers',
|
||||
{ ...BRIDGE_OPTIONS, enabled: false },
|
||||
resolveGuest
|
||||
)
|
||||
const fresh = browserManager.setAnnotationViewportBridge(
|
||||
'tab-fresh-markers',
|
||||
{
|
||||
...MARKER_OPTIONS,
|
||||
token: 'remintedpreviewtoken',
|
||||
markers: MARKER_OPTIONS.markers.map((marker) => ({ ...marker, id: 'fresh-preview-marker' }))
|
||||
},
|
||||
resolveGuest
|
||||
)
|
||||
|
||||
release()
|
||||
await expect(inFlight).resolves.toBe(true)
|
||||
await expect(cleared).resolves.toBe(false)
|
||||
await expect(fresh).resolves.toBe(true)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
|
||||
expect.any(Number),
|
||||
[
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('fresh-preview-marker')
|
||||
})
|
||||
],
|
||||
false
|
||||
)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
|
||||
expect.any(Number),
|
||||
[expect.objectContaining({ code: expect.stringContaining('remintedpreviewtoken') })],
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('continues the queue after an in-flight injection fails', async () => {
|
||||
const { guest } = makeGuest(5204)
|
||||
let rejectInjection = (): void => {}
|
||||
const gate = new Promise<void>((_resolve, reject) => {
|
||||
rejectInjection = () => reject(new Error('retired document'))
|
||||
})
|
||||
guest.executeJavaScriptInIsolatedWorld = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(gate)
|
||||
.mockResolvedValue(true)
|
||||
registerPage('tab-failed-injection', guest)
|
||||
const resolveGuest = resolveFromRegistry('tab-failed-injection')
|
||||
const inFlight = browserManager.setAnnotationViewportBridge(
|
||||
'tab-failed-injection',
|
||||
MARKER_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
await flushViewportOps()
|
||||
const cleared = browserManager.setAnnotationViewportBridge(
|
||||
'tab-failed-injection',
|
||||
{ ...BRIDGE_OPTIONS, enabled: false },
|
||||
resolveGuest
|
||||
)
|
||||
|
||||
rejectInjection()
|
||||
await expect(inFlight).resolves.toBe(false)
|
||||
await expect(cleared).resolves.toBe(true)
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge(
|
||||
'tab-failed-injection',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('does not run a queued marker request after page teardown removes its chain', async () => {
|
||||
const { guest } = makeGuest(5205)
|
||||
const release = deferGuestInjection(guest)
|
||||
registerPage('tab-closed-queue', guest)
|
||||
const resolveGuest = resolveFromRegistry('tab-closed-queue')
|
||||
const inFlight = browserManager.setAnnotationViewportBridge(
|
||||
'tab-closed-queue',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
await flushViewportOps()
|
||||
const retired = browserManager.setAnnotationViewportBridge(
|
||||
'tab-closed-queue',
|
||||
MARKER_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
browserManager.unregisterGuest('tab-closed-queue')
|
||||
|
||||
release()
|
||||
await expect(inFlight).resolves.toBe(true)
|
||||
await expect(retired).resolves.toBe(false)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
|
||||
expect(browserManager.getGuestWebContentsId('tab-closed-queue')).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
|
||||
// is the one that can see the guest is gone. The bridge only reports the refusal.
|
||||
it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
|
||||
|
||||
@@ -77,7 +77,13 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec
|
||||
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.catch(() => {})
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
|
||||
.then(() => {
|
||||
// A newer document invalidation retires geometry still waiting in the queue.
|
||||
if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) !== next) {
|
||||
return false
|
||||
}
|
||||
return this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)
|
||||
})
|
||||
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
|
||||
try {
|
||||
return await next
|
||||
|
||||
@@ -8,6 +8,7 @@ import { deriveToolInputPreview } from '../../shared/agent-hook-listener/tool-in
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { ClaudeChildWorkDecoder } from './claude-child-work-decoder'
|
||||
import { claudeChildOperation, drainClaudeChildWork } from './claude-child-work-evidence'
|
||||
import { ClaudePromptRegistry } from './claude-prompt-registry'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
import {
|
||||
claudeToolResults,
|
||||
@@ -85,7 +86,11 @@ describe('Claude child operation output retention', () => {
|
||||
translator.handle({ type: 'message', sessionId: 'orca', message, observedAt: 1_000 })
|
||||
const join = vi.spyOn(Array.prototype, 'join')
|
||||
const evidence = drainClaudeChildWork(
|
||||
{ childWork: new ClaudeChildWorkDecoder(), translator },
|
||||
{
|
||||
childWork: new ClaudeChildWorkDecoder(),
|
||||
prompts: new ClaudePromptRegistry(),
|
||||
translator
|
||||
},
|
||||
message,
|
||||
1_000
|
||||
)
|
||||
|
||||
@@ -46,6 +46,25 @@ describe('resolveRealClaudeCliGate', () => {
|
||||
expect(runClaude.mock.calls).toEqual([[['--version']], [['auth', 'status', '--json']]])
|
||||
})
|
||||
|
||||
it('reads the final account report after the CLI configuration warning', () => {
|
||||
const warning = [
|
||||
'Claude configuration file not found at: /home/dev/.claude/.claude.json',
|
||||
'A backup file exists at: /home/dev/.claude/backups/.claude.json.backup.123',
|
||||
'You can manually restore it by running: cp /home/dev/.claude/backups/.claude.json.backup.123 /home/dev/.claude/.claude.json',
|
||||
''
|
||||
].join('\n')
|
||||
const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult =>
|
||||
args[0] === '--version'
|
||||
? { status: 0, stdout: '2.1.0\n' }
|
||||
: { status: 0, stdout: warning + warning + JSON.stringify({ loggedIn: true }) }
|
||||
)
|
||||
|
||||
expect(resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude)).toEqual({
|
||||
skipReason: null,
|
||||
authStatus: { loggedIn: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('still skips when opted in but no claude binary answers', () => {
|
||||
const runClaude = vi.fn((): ClaudeCliProbeResult => ({ status: null, stdout: '' }))
|
||||
|
||||
@@ -57,7 +76,14 @@ describe('resolveRealClaudeCliGate', () => {
|
||||
|
||||
it.each([
|
||||
['a failed auth probe', { status: 1, stdout: '' }],
|
||||
['unparseable auth output', { status: 0, stdout: 'not json' }]
|
||||
['unparseable auth output', { status: 0, stdout: 'not json' }],
|
||||
['malformed JSON', { status: 0, stdout: '{"loggedIn":' }],
|
||||
['trailing output', { status: 0, stdout: '{"loggedIn":true}\nnot json' }],
|
||||
['two JSON objects', { status: 0, stdout: '{"loggedIn":false}\n{"loggedIn":true}' }],
|
||||
['another JSON value before an object', { status: 0, stdout: 'true\n{"loggedIn":true}' }],
|
||||
['an array before an object', { status: 0, stdout: '[]\n{"loggedIn":true}' }],
|
||||
['a JSON array', { status: 0, stdout: '[{"loggedIn":true}]' }],
|
||||
['an unrelated JSON value', { status: 0, stdout: 'true' }]
|
||||
])('runs with no account report after %s', (_label, authResult) => {
|
||||
const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult =>
|
||||
args[0] === '--version' ? { status: 0, stdout: '2.1.0\n' } : authResult
|
||||
|
||||
@@ -13,13 +13,26 @@ export type RealClaudeCliGate = {
|
||||
}
|
||||
|
||||
function parseAuthStatus(stdout: string): RealClaudeAuthStatus | null {
|
||||
const start = stdout.search(/[[{]/)
|
||||
if (start === -1) {
|
||||
return null
|
||||
}
|
||||
// CLI warnings may precede the report, but another JSON value makes it ambiguous.
|
||||
for (const line of stdout.slice(0, start).split('\n')) {
|
||||
try {
|
||||
JSON.parse(line)
|
||||
return null
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(stdout)
|
||||
parsed = JSON.parse(stdout.slice(start))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
const status: RealClaudeAuthStatus = {}
|
||||
|
||||
@@ -93,7 +93,8 @@ export class ClaudeJournalPrompts {
|
||||
const append = (identity: AgentJournalItemIdentity, body: ClaudeJournalPrompt['body']) => {
|
||||
const options = { ...producer, turnScope }
|
||||
if (this.deps.sink.tryAppendItem) {
|
||||
admitted &&= this.deps.sink.tryAppendItem(identity, body, options).accepted
|
||||
const admission = this.deps.sink.tryAppendItem(identity, body, options)
|
||||
admitted &&= admission.accepted
|
||||
} else {
|
||||
this.deps.sink.appendItem(identity, body, options)
|
||||
}
|
||||
|
||||
@@ -255,89 +255,129 @@ describe('Claude live prompt ownership', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a grouped prompt batch without partially revising its first row', () => {
|
||||
const tombstones: string[] = []
|
||||
const appendTombstone = vi.fn(
|
||||
(identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[0]) => {
|
||||
tombstones.push(agentJournalItemKey(identity))
|
||||
}
|
||||
)
|
||||
let rowAdmission = 0
|
||||
const tryAppendTombstone = vi.fn(
|
||||
(identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[0]) => {
|
||||
rowAdmission += 1
|
||||
if (rowAdmission === 2) {
|
||||
it.each([0, 1, null] as const)(
|
||||
'rejects a grouped prompt batch without partial revision after row %s admission refusal',
|
||||
async (refusedAt) => {
|
||||
const tombstones: string[] = []
|
||||
const appendTombstone = vi.fn(
|
||||
(identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[0]) => {
|
||||
tombstones.push(agentJournalItemKey(identity))
|
||||
}
|
||||
)
|
||||
let rowAdmission = 0
|
||||
const tryAppendTombstone = vi.fn(
|
||||
(identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[0]) => {
|
||||
rowAdmission += 1
|
||||
if (rowAdmission === 2) {
|
||||
return { accepted: false as const, reason: 'backpressure' as const }
|
||||
}
|
||||
appendTombstone(identity)
|
||||
return { accepted: true as const }
|
||||
}
|
||||
)
|
||||
const tryAppendLifecycleBatch = vi.fn(
|
||||
(
|
||||
_settlementId: string,
|
||||
mutations: Parameters<
|
||||
NonNullable<StructuredAgentSessionEventSink['tryAppendLifecycleBatch']>
|
||||
>[1]
|
||||
) => {
|
||||
expect(mutations[1]).toMatchObject({
|
||||
kind: 'item',
|
||||
body: { resolution: { state: 'cancelled' } }
|
||||
})
|
||||
return { accepted: false as const, reason: 'backpressure' as const }
|
||||
}
|
||||
appendTombstone(identity)
|
||||
return { accepted: true as const }
|
||||
)
|
||||
const admittedRows = new Map<string, AgentJournalItemBody>()
|
||||
let admissionAttempts = 0
|
||||
const tryAppendItem = vi.fn(
|
||||
(
|
||||
identity: Parameters<StructuredAgentSessionEventSink['appendItem']>[0],
|
||||
body: AgentJournalItemBody
|
||||
) => {
|
||||
if (admissionAttempts++ === refusedAt) {
|
||||
return { accepted: false as const, reason: 'backpressure' as const }
|
||||
}
|
||||
admittedRows.set(agentJournalItemKey(identity), body)
|
||||
return { accepted: true as const }
|
||||
}
|
||||
)
|
||||
const bindPromptItemId = vi.fn()
|
||||
const written = vi.fn(async () => ({ ok: true as const }))
|
||||
const prompts = new ClaudeJournalPrompts({
|
||||
sink: {
|
||||
appendItem: () => {},
|
||||
tryAppendItem,
|
||||
appendTombstone,
|
||||
tryAppendTombstone,
|
||||
tryAppendLifecycleBatch,
|
||||
publish: () => {},
|
||||
written
|
||||
},
|
||||
bindPromptItemId,
|
||||
producerOf: () => ({ agentId: 'agent-grouped', producerKind: 'agent' }),
|
||||
questionItems: (input) => {
|
||||
const item = claudeQuestionItems(input)[0]
|
||||
return item
|
||||
? [
|
||||
{
|
||||
...item,
|
||||
identity: { provider: 'orca', clientMessageId: 'group:first' }
|
||||
},
|
||||
{
|
||||
...item,
|
||||
identity: { provider: 'orca', clientMessageId: 'group:second' }
|
||||
}
|
||||
]
|
||||
: []
|
||||
},
|
||||
turnScope: () => AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
const prompt: ClaudePendingPrompt = {
|
||||
requestId: 'grouped-request',
|
||||
promptKey: 'grouped-request',
|
||||
toolUseId: 'tool-grouped',
|
||||
toolName: 'AskUserQuestion',
|
||||
kind: 'question',
|
||||
input: {
|
||||
questions: [
|
||||
{ question: 'First?', options: [{ label: 'Yes' }] },
|
||||
{ question: 'Second?', options: [{ label: 'No' }] }
|
||||
]
|
||||
},
|
||||
suggestions: [],
|
||||
questionIds: ['First?', 'Second?'],
|
||||
settle: vi.fn()
|
||||
}
|
||||
)
|
||||
const tryAppendLifecycleBatch = vi.fn(
|
||||
(
|
||||
_settlementId: string,
|
||||
mutations: Parameters<
|
||||
NonNullable<StructuredAgentSessionEventSink['tryAppendLifecycleBatch']>
|
||||
>[1]
|
||||
) => {
|
||||
expect(mutations[1]).toMatchObject({
|
||||
kind: 'item',
|
||||
body: { resolution: { state: 'cancelled' } }
|
||||
})
|
||||
return { accepted: false as const, reason: 'backpressure' as const }
|
||||
}
|
||||
)
|
||||
const prompts = new ClaudeJournalPrompts({
|
||||
sink: {
|
||||
appendItem: () => {},
|
||||
appendTombstone,
|
||||
tryAppendTombstone,
|
||||
tryAppendLifecycleBatch,
|
||||
publish: () => {}
|
||||
},
|
||||
questionItems: (input) => {
|
||||
const item = claudeQuestionItems(input)[0]
|
||||
return item
|
||||
? [
|
||||
{
|
||||
...item,
|
||||
identity: { provider: 'orca', clientMessageId: 'group:first' }
|
||||
},
|
||||
{
|
||||
...item,
|
||||
identity: { provider: 'orca', clientMessageId: 'group:second' }
|
||||
}
|
||||
]
|
||||
: []
|
||||
},
|
||||
turnScope: () => AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
const prompt: ClaudePendingPrompt = {
|
||||
requestId: 'grouped-request',
|
||||
promptKey: 'grouped-request',
|
||||
toolUseId: 'tool-grouped',
|
||||
toolName: 'AskUserQuestion',
|
||||
kind: 'question',
|
||||
input: {
|
||||
questions: [
|
||||
{ question: 'First?', options: [{ label: 'Yes' }] },
|
||||
{ question: 'Second?', options: [{ label: 'No' }] }
|
||||
]
|
||||
},
|
||||
suggestions: [],
|
||||
questionIds: ['First?', 'Second?'],
|
||||
settle: vi.fn()
|
||||
}
|
||||
prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt })
|
||||
prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt })
|
||||
await prompts.whenWritten(prompt.promptKey)
|
||||
|
||||
expect(prompts.cancel(prompt.promptKey)).toEqual({
|
||||
accepted: false,
|
||||
reason: 'backpressure'
|
||||
})
|
||||
expect(tryAppendLifecycleBatch).toHaveBeenCalledOnce()
|
||||
expect(tryAppendTombstone).not.toHaveBeenCalled()
|
||||
expect(tombstones).toEqual([])
|
||||
})
|
||||
expect(tryAppendItem.mock.calls.map(([identity]) => agentJournalItemKey(identity))).toEqual([
|
||||
'orca:group%3Afirst',
|
||||
'orca:group%3Asecond'
|
||||
])
|
||||
expect([...admittedRows.keys()]).toEqual(
|
||||
['orca:group%3Afirst', 'orca:group%3Asecond'].filter((_, index) => index !== refusedAt)
|
||||
)
|
||||
expect(bindPromptItemId.mock.calls).toEqual([
|
||||
['orca:group%3Afirst', prompt.promptKey],
|
||||
['orca:group%3Asecond', prompt.promptKey]
|
||||
])
|
||||
expect(written).toHaveBeenCalledTimes(refusedAt === null ? 1 : 0)
|
||||
expect([...prompts.openCards()]).toEqual(
|
||||
refusedAt === null ? [{ promptKey: prompt.promptKey, asker: 'agent-grouped' }] : []
|
||||
)
|
||||
|
||||
expect(prompts.cancel(prompt.promptKey)).toEqual({
|
||||
accepted: false,
|
||||
reason: 'backpressure'
|
||||
})
|
||||
expect(tryAppendLifecycleBatch).toHaveBeenCalledOnce()
|
||||
expect(tryAppendTombstone).not.toHaveBeenCalled()
|
||||
expect(tombstones).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps every backpressured prompt cancellation retry in its owned entry', () => {
|
||||
let backpressured = true
|
||||
|
||||
@@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { basename, join, relative } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
|
||||
import * as claudeConnection from './claude-stream-json-connection'
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
|
||||
import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution'
|
||||
@@ -11,7 +13,8 @@ import {
|
||||
realClaudeAuthStatus,
|
||||
realClaudeAvailable,
|
||||
realClaudeCliGate,
|
||||
realClaudeCommand
|
||||
realClaudeCommand,
|
||||
realClaudeLaunchHome
|
||||
} from './claude-real-cli-availability-test-support'
|
||||
import {
|
||||
ClaudeStructuredSessionAdapter,
|
||||
@@ -27,13 +30,15 @@ function realAdapter(
|
||||
claudeConfigDir: string,
|
||||
events: ClaudeStructuredSessionEvent[] = [],
|
||||
cwd = process.cwd(),
|
||||
onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate']
|
||||
onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'],
|
||||
env = realClaudeLaunchHome().env
|
||||
): ClaudeStructuredSessionAdapter {
|
||||
const adapter = new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: async () => ({
|
||||
pathToClaudeCodeExecutable: command,
|
||||
options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: providerSessionId },
|
||||
cwd,
|
||||
env,
|
||||
claudeConfigDir,
|
||||
providerSessionId,
|
||||
resumeLeafUuid: null,
|
||||
@@ -81,6 +86,53 @@ async function waitForResolvedTranscript(
|
||||
}
|
||||
}
|
||||
|
||||
describe('real CLI fixture authentication isolation', () => {
|
||||
it.each(['authenticated', 'signed-out'] as const)(
|
||||
'keeps the %s launch environment independent of inherited credentials',
|
||||
async (mode) => {
|
||||
const authKeys = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'] as const
|
||||
for (const key of authKeys) {
|
||||
vi.stubEnv(key, 'unusable-fixture-auth-value')
|
||||
}
|
||||
let childEnv: Record<string, string> | undefined
|
||||
const open = vi
|
||||
.spyOn(claudeConnection, 'openClaudeStreamJsonConnection')
|
||||
.mockImplementation(async (launch) => {
|
||||
childEnv = buildClaudeChildProcessEnv(launch.env)
|
||||
throw new Error('fixture stopped before spawning a CLI')
|
||||
})
|
||||
const providerSessionId = randomUUID()
|
||||
const adapter = realAdapter(
|
||||
providerSessionId,
|
||||
join(tmpdir(), 'orca-synthetic-signed-out'),
|
||||
[],
|
||||
process.cwd(),
|
||||
undefined,
|
||||
mode === 'signed-out' ? {} : undefined
|
||||
)
|
||||
try {
|
||||
await expect(
|
||||
adapter.acquire({
|
||||
identity: identity(providerSessionId),
|
||||
fence: 1,
|
||||
spawnToken: 'fixture'
|
||||
})
|
||||
).rejects.toThrow('fixture stopped before spawning a CLI')
|
||||
expect(open).toHaveBeenCalledOnce()
|
||||
for (const key of authKeys) {
|
||||
expect(childEnv?.[key]).toBe(
|
||||
mode === 'authenticated' ? 'unusable-fixture-auth-value' : undefined
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
open.mockRestore()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
|
||||
it.skipIf(!realClaudeAuthenticated)(
|
||||
'proves a pre-minted session before the first user message',
|
||||
@@ -442,7 +494,14 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
|
||||
const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-'))
|
||||
const providerSessionId = randomUUID()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = realAdapter(providerSessionId, claudeConfigDir, events)
|
||||
const adapter = realAdapter(
|
||||
providerSessionId,
|
||||
claudeConfigDir,
|
||||
events,
|
||||
process.cwd(),
|
||||
undefined,
|
||||
{}
|
||||
)
|
||||
|
||||
try {
|
||||
await adapter.acquire({
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('prependOrcaCliDirToChildPath', () => {
|
||||
platform: 'linux'
|
||||
})
|
||||
expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`)
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBe(SHIM_DIR)
|
||||
expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({
|
||||
userDataPath: USER_DATA
|
||||
})
|
||||
@@ -44,13 +45,14 @@ describe('prependOrcaCliDirToChildPath', () => {
|
||||
|
||||
it('leaves packaged Linux PATH untouched when no shim could be written', () => {
|
||||
shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null)
|
||||
const env: Record<string, string> = { PATH: '/usr/bin' }
|
||||
const env: Record<string, string> = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/old-host/cli' }
|
||||
prependOrcaCliDirToChildPath(env, {
|
||||
isPackaged: true,
|
||||
userDataPath: USER_DATA,
|
||||
platform: 'linux'
|
||||
})
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leads packaged macOS PATH with the bundled CLI dir', () => {
|
||||
@@ -62,11 +64,16 @@ describe('prependOrcaCliDirToChildPath', () => {
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`)
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBe(join(RESOURCES, 'bin'))
|
||||
expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leads packaged Windows PATH with the bundled CLI dir under the env block spelling', () => {
|
||||
const env: Record<string, string> = { Path: 'C:\\Windows\\System32' }
|
||||
const env: Record<string, string> = {
|
||||
Path: 'C:\\Windows\\System32',
|
||||
ORCA_CLI_BIN_DIR: '/parent-host/cli',
|
||||
ORCA_WSL_CLI_DIR: '/guest/orca/bin'
|
||||
}
|
||||
prependOrcaCliDirToChildPath(env, {
|
||||
isPackaged: true,
|
||||
userDataPath: USER_DATA,
|
||||
@@ -75,6 +82,8 @@ describe('prependOrcaCliDirToChildPath', () => {
|
||||
})
|
||||
expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows\\System32`)
|
||||
expect(env.PATH).toBeUndefined()
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBeUndefined()
|
||||
expect(env.ORCA_WSL_CLI_DIR).toBe('/guest/orca/bin')
|
||||
})
|
||||
|
||||
it('leaves a packaged darwin/win32 PATH alone with no resources root', () => {
|
||||
@@ -101,6 +110,9 @@ describe('prependOrcaCliDirToChildPath', () => {
|
||||
platform
|
||||
})
|
||||
expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}${pathDelimiter}/usr/bin`)
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBe(
|
||||
platform === 'win32' ? undefined : join(USER_DATA, 'cli', 'bin')
|
||||
)
|
||||
expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -39,12 +39,16 @@ export function prependOrcaCliDirToChildPath(
|
||||
opts: OrcaCliChildPathOptions
|
||||
): string | null {
|
||||
const platform = opts.platform ?? process.platform
|
||||
delete env.ORCA_CLI_BIN_DIR
|
||||
// Why: matches node:path's `delimiter` for the running platform, but stays correct when a test
|
||||
// drives a foreign platform through the seam.
|
||||
const pathDelimiter = platform === 'win32' ? ';' : delimiter
|
||||
// Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
|
||||
if (!opts.isPackaged) {
|
||||
const devCliBin = join(opts.userDataPath, 'cli', 'bin')
|
||||
if (platform !== 'win32') {
|
||||
env.ORCA_CLI_BIN_DIR = devCliBin
|
||||
}
|
||||
const inheritedPath = readInheritedPath(env, platform)
|
||||
// Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter.
|
||||
env[resolvePathEnvKey(env, platform)] = inheritedPath
|
||||
@@ -55,6 +59,7 @@ export function prependOrcaCliDirToChildPath(
|
||||
// Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904).
|
||||
const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath })
|
||||
if (shimDir) {
|
||||
env.ORCA_CLI_BIN_DIR = shimDir
|
||||
const inheritedEntries = readInheritedPath(env, platform)
|
||||
.split(pathDelimiter)
|
||||
.filter((entry) => entry.length > 0 && entry !== shimDir)
|
||||
@@ -64,6 +69,9 @@ export function prependOrcaCliDirToChildPath(
|
||||
} else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) {
|
||||
// Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI.
|
||||
const bundledCliBin = join(opts.resourcesPath, 'bin')
|
||||
if (platform === 'darwin') {
|
||||
env.ORCA_CLI_BIN_DIR = bundledCliBin
|
||||
}
|
||||
const inheritedPath = readInheritedPath(env, platform)
|
||||
env[resolvePathEnvKey(env, platform)] = inheritedPath
|
||||
? `${bundledCliBin}${pathDelimiter}${inheritedPath}`
|
||||
|
||||
@@ -88,6 +88,49 @@ describe('legacy shared Codex config compatibility', () => {
|
||||
expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth)
|
||||
})
|
||||
|
||||
it('keeps an Orca-added MCP server while settings and trust still refresh', () => {
|
||||
const baselinePath = join(sharedRuntimeHome, '.orca-config-settings-baseline.json')
|
||||
const baseline = JSON.stringify({ version: 3, settings: {}, mcpServers: [] })
|
||||
writeFileSync(baselinePath, baseline)
|
||||
writeFileSync(
|
||||
join(systemCodexHome, 'config.toml'),
|
||||
['model = "canonical"', '', '[projects."/revoked"]', 'trust_level = "untrusted"', ''].join(
|
||||
'\n'
|
||||
)
|
||||
)
|
||||
writeFileSync(
|
||||
join(sharedRuntimeHome, 'config.toml'),
|
||||
[
|
||||
'model = "stale"',
|
||||
'',
|
||||
'[projects."/trusted-in-orca"]',
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
'[projects."/revoked"]',
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
'[hooks.state."orca:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'',
|
||||
'[mcp_servers.demo-mcp]',
|
||||
'command = "demo"',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome })
|
||||
|
||||
const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')
|
||||
expect(sharedConfig).toContain('model = "canonical"')
|
||||
expect(sharedConfig).not.toContain('model = "stale"')
|
||||
expect(sharedConfig).toContain('[projects."/trusted-in-orca"]\ntrust_level = "trusted"')
|
||||
expect(sharedConfig).toContain('[projects."/revoked"]\ntrust_level = "untrusted"')
|
||||
expect(sharedConfig).not.toContain('[projects."/revoked"]\ntrust_level = "trusted"')
|
||||
expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]')
|
||||
expect(sharedConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "demo"')
|
||||
expect(readFileSync(baselinePath, 'utf-8')).toBe(baseline)
|
||||
})
|
||||
|
||||
it('does not delete config when the canonical source is transiently missing', () => {
|
||||
const staleConfig = 'model_provider = "stale-provider"\n'
|
||||
writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8')
|
||||
|
||||
@@ -21,6 +21,20 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
const BASELINE_FILE = '.orca-config-settings-baseline.json'
|
||||
|
||||
type StoredBaselineFixture = {
|
||||
version: 1 | 3
|
||||
settings: Record<string, string | null>
|
||||
mcpServers?: string[]
|
||||
}
|
||||
|
||||
function writeBaseline(baseline: StoredBaselineFixture): string {
|
||||
const serialized = JSON.stringify(baseline)
|
||||
writeFileSync(join(runtimeHomePath, BASELINE_FILE), serialized)
|
||||
return serialized
|
||||
}
|
||||
|
||||
describe('canonical MCP ownership during config mirroring', () => {
|
||||
it('does not duplicate a server defined inline in the canonical MCP table', () => {
|
||||
writeFileSync(
|
||||
@@ -130,6 +144,60 @@ describe('MCP ownership migration', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the retained shared home canonical under a pre-ownership baseline', () => {
|
||||
writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n')
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
|
||||
writeBaseline({ version: 1, settings: {} })
|
||||
|
||||
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain(
|
||||
'[mcp_servers.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Orca-only servers in the retained shared home and drops ones removed from ~/.codex', () => {
|
||||
writeFileSync(
|
||||
join(runtimeHomePath, 'config.toml'),
|
||||
[
|
||||
'[mcp_servers.demo-mcp]',
|
||||
'command = "orca-only"',
|
||||
'[mcp_servers.removed]',
|
||||
'command = "mirrored"',
|
||||
'[mcp_servers.kept]',
|
||||
'command = "stale"',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemHomePath, 'config.toml'),
|
||||
'model = "system"\n[mcp_servers.kept]\ncommand = "system"\n'
|
||||
)
|
||||
const baseline = writeBaseline({ version: 3, settings: {}, mcpServers: ['removed', 'kept'] })
|
||||
|
||||
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')
|
||||
expect(runtimeConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "orca-only"')
|
||||
expect(runtimeConfig).not.toContain('[mcp_servers.removed]')
|
||||
expect(runtimeConfig).toContain('[mcp_servers.kept]\ncommand = "system"')
|
||||
expect(runtimeConfig).not.toContain('"stale"')
|
||||
expect(readFileSync(join(runtimeHomePath, BASELINE_FILE), 'utf-8')).toBe(baseline)
|
||||
})
|
||||
|
||||
it('leaves the retained shared home untouched when its baseline cannot be read', () => {
|
||||
const runtimeConfig = 'model = "retained"\n[mcp_servers.demo-mcp]\ncommand = "orca-only"\n'
|
||||
writeFileSync(join(runtimeHomePath, 'config.toml'), runtimeConfig)
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
|
||||
mkdirSync(join(runtimeHomePath, BASELINE_FILE))
|
||||
|
||||
expect(() =>
|
||||
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
).toThrow()
|
||||
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(runtimeConfig)
|
||||
})
|
||||
|
||||
it('tracks commented CRLF names so their later removal remains authoritative', () => {
|
||||
writeFileSync(
|
||||
join(runtimeHomePath, 'config.toml'),
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type CodexSettingsPromotionHomes,
|
||||
type CodexSettingsPromotionPlan
|
||||
} from './config-settings-promotion'
|
||||
import { readCodexSettingsBaseline } from './config-settings-baseline'
|
||||
import { observeCodexSettingsBaseline, readCodexSettingsBaseline } from './config-settings-baseline'
|
||||
import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config-sync-stall'
|
||||
import { preserveRuntimeConflictValues } from './codex-config-settings-preservation'
|
||||
import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard'
|
||||
@@ -161,15 +161,13 @@ export function syncSystemConfigIntoLegacySharedCodexHome(
|
||||
let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? ''
|
||||
if (rawSystemConfig.trim() !== '') {
|
||||
const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath)
|
||||
// The retired home has no ownership baseline; its entire MCP root stays canonical.
|
||||
mirroredRuntimeConfig =
|
||||
runtimeConfigBeforeMirror !== null
|
||||
? mergeSystemCodexConfigIntoRuntime(
|
||||
runtimeConfigBeforeMirror,
|
||||
prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir),
|
||||
sourceConfigDir,
|
||||
new Set(),
|
||||
true
|
||||
...readLegacySharedHomeMcpOwnership(homes.runtimeHomePath)
|
||||
)
|
||||
: prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
|
||||
}
|
||||
@@ -186,6 +184,24 @@ export function syncSystemConfigIntoLegacySharedCodexHome(
|
||||
writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: MCP servers added from an Orca terminal exist only in the retired home,
|
||||
* so its last mirror's baseline decides which ones ~/.codex owns. With no
|
||||
* baseline the whole root stays canonical, as before; an unreadable one throws
|
||||
* rather than guess. Read-only: this one-way refresh never advances it.
|
||||
*/
|
||||
function readLegacySharedHomeMcpOwnership(
|
||||
runtimeHomePath: string
|
||||
): [mirroredMcpServerNames: ReadonlySet<string>, mirroredMcpServerRoot: boolean] {
|
||||
const observation = observeCodexSettingsBaseline(runtimeHomePath)
|
||||
if (observation.kind === 'indeterminate') {
|
||||
throw new Error('Codex settings baseline could not be read')
|
||||
}
|
||||
return observation.kind === 'present'
|
||||
? [observation.baseline.mcpServers, observation.baseline.mcpServerRoot]
|
||||
: [new Set(), true]
|
||||
}
|
||||
|
||||
type CodexConfigMirrorResult =
|
||||
| { status: 'skipped-missing-source' }
|
||||
| { status: 'refused-indeterminate'; error: unknown }
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../runtime/structured-worker-identity'
|
||||
|
||||
const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/
|
||||
const DEV_CLI_BIN_DIR = /^[^:;]*[\\/]cli[\\/]bin$/
|
||||
// The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global.
|
||||
const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/
|
||||
|
||||
@@ -23,7 +24,11 @@ describe('buildCodexStructuredChildEnvironment', () => {
|
||||
cwd: '/worktree',
|
||||
codexHome: '/pinned/home',
|
||||
resumeThreadId: null,
|
||||
env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' }
|
||||
env: {
|
||||
EXAMPLE_GATEWAY_TOKEN: 'shell-exported',
|
||||
CODEX_HOME: '/shell/home',
|
||||
ORCA_CLI_BIN_DIR: '/inherited/unowned-cli'
|
||||
}
|
||||
},
|
||||
'spawn-token',
|
||||
'session-not-a-worker'
|
||||
@@ -35,6 +40,9 @@ describe('buildCodexStructuredChildEnvironment', () => {
|
||||
ORCA_AGENT_SESSION_ID: 'session-not-a-worker',
|
||||
ORCA_STRUCTURED_SESSION: '1',
|
||||
ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER),
|
||||
...(process.platform !== 'win32'
|
||||
? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) }
|
||||
: {}),
|
||||
ORCA_USER_DATA_PATH: expect.any(String),
|
||||
// The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH.
|
||||
PATH: expect.stringMatching(DEV_CLI_BIN_FIRST)
|
||||
@@ -57,6 +65,9 @@ describe('buildCodexStructuredChildEnvironment', () => {
|
||||
ORCA_AGENT_SESSION_ID: sessionId,
|
||||
ORCA_STRUCTURED_SESSION: '1',
|
||||
ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER),
|
||||
...(process.platform !== 'win32'
|
||||
? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) }
|
||||
: {}),
|
||||
ORCA_USER_DATA_PATH: expect.any(String),
|
||||
PATH: expect.stringMatching(DEV_CLI_BIN_FIRST)
|
||||
})
|
||||
@@ -97,6 +108,7 @@ const ENV_REPORTING_APP_SERVER = String.raw`
|
||||
result: {
|
||||
sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null,
|
||||
cliCommand: process.env.ORCA_CLI_COMMAND ?? null,
|
||||
cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null,
|
||||
path: process.env.PATH ?? process.env.Path ?? null
|
||||
}
|
||||
})
|
||||
@@ -135,6 +147,7 @@ describe('the spawned Codex child', () => {
|
||||
await expect(connection.request('test/env')).resolves.toEqual({
|
||||
sessionId,
|
||||
cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER),
|
||||
cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR),
|
||||
path: expect.stringMatching(DEV_CLI_BIN_FIRST)
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -38,6 +38,9 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
|
||||
ORCA_AGENT_SESSION_ID: 'session-1',
|
||||
ORCA_STRUCTURED_SESSION: '1',
|
||||
ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/),
|
||||
...(process.platform !== 'win32'
|
||||
? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) }
|
||||
: {}),
|
||||
ORCA_USER_DATA_PATH: expect.any(String),
|
||||
// The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH.
|
||||
PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
|
||||
import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path'
|
||||
import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell'
|
||||
import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function'
|
||||
import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition'
|
||||
@@ -35,6 +36,7 @@ __orca_restore_agent_teams_path() {
|
||||
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
${ORCA_CLI_POSIX_PATH_RESTORE}
|
||||
# Why: user startup files may set the default OpenCode config after Orca's
|
||||
# spawn env; restore the Orca-managed config dir before the first prompt.
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import './mock-descendant-sweep'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type * as CryptoModule from 'node:crypto'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { DaemonClient } from './client'
|
||||
import { DaemonClientConnections } from './daemon-client-connections'
|
||||
import { TerminalHost } from './terminal-host'
|
||||
import { Session } from './session'
|
||||
import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
|
||||
import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
|
||||
type MockSubprocess = SubprocessHandle & { exit: () => void }
|
||||
const seed = vi.hoisted(() => ({ uuid: 0 }))
|
||||
vi.mock('node:crypto', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof CryptoModule>()
|
||||
return {
|
||||
...original,
|
||||
randomUUID: () => `00000000-0000-4000-8000-${String(++seed.uuid).padStart(12, '0')}`
|
||||
}
|
||||
})
|
||||
const clients: DaemonClient[] = []
|
||||
const processes: MockSubprocess[] = []
|
||||
let dir = ''
|
||||
let server: DaemonServer | undefined
|
||||
|
||||
async function waitFor(predicate: () => boolean): Promise<void> {
|
||||
const deadline = performance.now() + 2_000
|
||||
while (!predicate()) {
|
||||
if (performance.now() >= deadline) {
|
||||
throw new Error('Daemon event did not settle')
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
}
|
||||
|
||||
function spawn(): MockSubprocess {
|
||||
let exited: ((code: number) => void) | undefined
|
||||
const proc: MockSubprocess = {
|
||||
pid: 90_001,
|
||||
getForegroundProcess: () => null,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: () => exited?.(0),
|
||||
forceKill: () => exited?.(137),
|
||||
terminateOwnedTree: () => 'unavailable',
|
||||
signal: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit: (callback) => {
|
||||
exited = callback
|
||||
},
|
||||
dispose: vi.fn(),
|
||||
exit: () => exited?.(0)
|
||||
}
|
||||
processes.push(proc)
|
||||
return proc
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-idle-inventory-'))
|
||||
clients.length = 0
|
||||
processes.length = 0
|
||||
seed.uuid = 0
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const process of processes) {
|
||||
process.exit()
|
||||
}
|
||||
for (const client of clients) {
|
||||
client.disconnect()
|
||||
}
|
||||
await server?.shutdown()
|
||||
vi.restoreAllMocks()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('keeps live sessions and exact inventories while visitors repeatedly disconnect', async () => {
|
||||
const socketPath = getDaemonSocketPath(dir)
|
||||
const tokenPath = join(dir, 'daemon.token')
|
||||
const idle = vi.fn()
|
||||
const getClient = vi.spyOn(DaemonClientConnections.prototype, 'get')
|
||||
const create = vi.spyOn(TerminalHost.prototype, 'createOrAttach')
|
||||
server = new DaemonServer({ socketPath, tokenPath, spawnSubprocess: spawn, onIdleShutdown: idle })
|
||||
await server.start()
|
||||
const owner = new DaemonClient({ socketPath, tokenPath })
|
||||
clients.push(owner)
|
||||
await owner.ensureConnected()
|
||||
for (let index = 0; index < 16; index++) {
|
||||
await owner.request('createOrAttach', {
|
||||
sessionId: `session-${index}`,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
...(index < 8
|
||||
? {
|
||||
agentSessionEnsure: {
|
||||
claim: {
|
||||
digestVersion: 1,
|
||||
keyId: `key${index}`,
|
||||
identityDigest: 'a'.repeat(43),
|
||||
worktreeScopeDigest: 'b'.repeat(43),
|
||||
agent: 'codex'
|
||||
},
|
||||
surface: {
|
||||
worktreeId: `folder${index}`,
|
||||
tabId: '11111111-1111-4111-8111-111111111111',
|
||||
leafId: '22222222-2222-4222-8222-222222222222',
|
||||
terminalHandle: `term_idle${index}`
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
const connections = getClient.mock.contexts[0]
|
||||
if (!(connections instanceof DaemonClientConnections)) {
|
||||
throw new Error('Actual server connections were not used')
|
||||
}
|
||||
const before = await owner.request('listSessions', undefined)
|
||||
const expected = structuredClone(before)
|
||||
if (
|
||||
typeof before !== 'object' ||
|
||||
before === null ||
|
||||
!('sessions' in before) ||
|
||||
!Array.isArray(before.sessions)
|
||||
) {
|
||||
throw new Error('Initial inventory was malformed')
|
||||
}
|
||||
const first = before.sessions[0]
|
||||
if (
|
||||
typeof first !== 'object' ||
|
||||
first === null ||
|
||||
!('agentSessionOwners' in first) ||
|
||||
!Array.isArray(first.agentSessionOwners) ||
|
||||
!first.agentSessionOwners.every(isAgentSessionOwnerBinding)
|
||||
) {
|
||||
throw new Error('Initial owners were malformed')
|
||||
}
|
||||
const priorOwner = first.agentSessionOwners[0]
|
||||
priorOwner.claim.keyId = 'mutated-client-copy'
|
||||
priorOwner.surface.terminalHandle = 'term_mutated'
|
||||
const inventory = vi.spyOn(TerminalHost.prototype, 'listSessions')
|
||||
const owners = vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty')
|
||||
const sizes = vi.spyOn(Session.prototype, 'getAppliedSize')
|
||||
const cwds = vi.spyOn(Session.prototype, 'getCwd')
|
||||
const originalAlive = Object.getOwnPropertyDescriptor(Session.prototype, 'isAlive')?.get
|
||||
if (!originalAlive) {
|
||||
throw new Error('Session alive getter is missing')
|
||||
}
|
||||
const alive = vi.spyOn(Session.prototype, 'isAlive', 'get')
|
||||
for (let iteration = 0; iteration < 10; iteration++) {
|
||||
const visitor = new DaemonClient({ socketPath, tokenPath })
|
||||
clients.push(visitor)
|
||||
await visitor.ensureConnected()
|
||||
expect(await visitor.request('ping', undefined)).toEqual({ pong: true })
|
||||
visitor.disconnect()
|
||||
await waitFor(() => connections.size === 1 && connections.transportCount === 2)
|
||||
expect(idle).not.toHaveBeenCalled()
|
||||
}
|
||||
const calls = inventory.mock.calls.length
|
||||
const sizeCalls = sizes.mock.calls.length
|
||||
const cwdCalls = cwds.mock.calls.length
|
||||
const readSessionIds = (): string[] =>
|
||||
alive.mock.contexts.map((session) => {
|
||||
if (!(session instanceof Session)) {
|
||||
throw new Error('Actual Session was not read')
|
||||
}
|
||||
return session.sessionId
|
||||
})
|
||||
const aliveReads = readSessionIds()
|
||||
const ownerCalls = owners.mock.calls.length
|
||||
const copiedOwners = owners.mock.results.reduce((total, result) => {
|
||||
const value: unknown = result.value
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('Owner result was not an array')
|
||||
}
|
||||
return total + value.length
|
||||
}, 0)
|
||||
const after = await owner.request('listSessions', undefined)
|
||||
expect(after).toEqual(expected)
|
||||
expect(JSON.stringify(after)).toBe(JSON.stringify(expected))
|
||||
expect(after).not.toBe(before)
|
||||
if (
|
||||
typeof after !== 'object' ||
|
||||
after === null ||
|
||||
!('sessions' in after) ||
|
||||
!Array.isArray(after.sessions)
|
||||
) {
|
||||
throw new Error('Fresh inventory was malformed')
|
||||
}
|
||||
const fresh = after.sessions[0]
|
||||
if (
|
||||
typeof fresh !== 'object' ||
|
||||
fresh === null ||
|
||||
!('agentSessionOwners' in fresh) ||
|
||||
!Array.isArray(fresh.agentSessionOwners) ||
|
||||
!fresh.agentSessionOwners.every(isAgentSessionOwnerBinding)
|
||||
) {
|
||||
throw new Error('Fresh owners were malformed')
|
||||
}
|
||||
expect(fresh.agentSessionOwners).not.toBe(first.agentSessionOwners)
|
||||
expect(fresh.agentSessionOwners[0]).not.toBe(priorOwner)
|
||||
expect(fresh.agentSessionOwners[0].claim).not.toBe(priorOwner.claim)
|
||||
expect(fresh.agentSessionOwners[0].surface).not.toBe(priorOwner.surface)
|
||||
expect(idle).not.toHaveBeenCalled()
|
||||
const host = create.mock.contexts[0]
|
||||
if (!(host instanceof TerminalHost)) {
|
||||
throw new Error('Actual host was not used')
|
||||
}
|
||||
const hasLive = (): boolean => {
|
||||
const candidate: unknown = host
|
||||
if (
|
||||
typeof candidate === 'object' &&
|
||||
candidate !== null &&
|
||||
'hasLiveSessions' in candidate &&
|
||||
typeof candidate.hasLiveSessions === 'function'
|
||||
) {
|
||||
const result: unknown = candidate.hasLiveSessions()
|
||||
if (typeof result !== 'boolean') {
|
||||
throw new Error('Live result was not boolean')
|
||||
}
|
||||
return result
|
||||
}
|
||||
return host.listSessions().length > 0
|
||||
}
|
||||
const readAlive = (session: Session): boolean => {
|
||||
const result: unknown = originalAlive.call(session)
|
||||
if (typeof result !== 'boolean') {
|
||||
throw new Error('Original alive getter was malformed')
|
||||
}
|
||||
return result
|
||||
}
|
||||
const controlReads: string[][] = []
|
||||
const failure = new Error('later-session-alive-failure')
|
||||
alive.mockImplementation(function (this: Session) {
|
||||
if (this.sessionId === 'session-1') {
|
||||
throw failure
|
||||
}
|
||||
return readAlive(this)
|
||||
})
|
||||
let caught: unknown
|
||||
try {
|
||||
hasLive()
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBe(failure)
|
||||
const sessionIds = Array.from({ length: 16 }, (_value, index) => `session-${index}`)
|
||||
const deadIds = new Set(sessionIds)
|
||||
alive.mockClear().mockImplementation(function (this: Session) {
|
||||
return !deadIds.has(this.sessionId) && readAlive(this)
|
||||
})
|
||||
expect(hasLive()).toBe(false)
|
||||
controlReads.push(readSessionIds())
|
||||
deadIds.delete('session-3')
|
||||
alive.mockClear()
|
||||
expect(hasLive()).toBe(true)
|
||||
controlReads.push(readSessionIds())
|
||||
deadIds.add('session-3')
|
||||
alive.mockClear()
|
||||
expect(hasLive()).toBe(false)
|
||||
controlReads.push(readSessionIds())
|
||||
let entered = false
|
||||
let nested: boolean | undefined
|
||||
alive.mockClear().mockImplementation(function (this: Session) {
|
||||
if (!entered) {
|
||||
entered = true
|
||||
processes[1].exit()
|
||||
nested = hasLive()
|
||||
}
|
||||
return readAlive(this)
|
||||
})
|
||||
expect(hasLive()).toBe(true)
|
||||
expect(nested).toBe(true)
|
||||
controlReads.push(readSessionIds())
|
||||
alive.mockRestore()
|
||||
if (
|
||||
typeof expected !== 'object' ||
|
||||
expected === null ||
|
||||
!('sessions' in expected) ||
|
||||
!Array.isArray(expected.sessions)
|
||||
) {
|
||||
throw new Error('Inventory was malformed')
|
||||
}
|
||||
const remaining = {
|
||||
...expected,
|
||||
sessions: expected.sessions.filter(
|
||||
(row: unknown) =>
|
||||
typeof row === 'object' &&
|
||||
row !== null &&
|
||||
'sessionId' in row &&
|
||||
row.sessionId !== 'session-1'
|
||||
)
|
||||
}
|
||||
const afterExit = await owner.request('listSessions', undefined)
|
||||
expect(afterExit).toEqual(remaining)
|
||||
expect(JSON.stringify(afterExit)).toBe(JSON.stringify(remaining))
|
||||
expect(aliveReads).toEqual(Array.from({ length: 30 }, () => sessionIds).flat())
|
||||
const remainingIds = sessionIds.filter((id) => id !== 'session-1')
|
||||
expect(controlReads).toEqual([
|
||||
sessionIds,
|
||||
sessionIds,
|
||||
sessionIds,
|
||||
[
|
||||
sessionIds[0],
|
||||
...sessionIds,
|
||||
sessionIds[1],
|
||||
...remainingIds,
|
||||
...remainingIds,
|
||||
...sessionIds.slice(2)
|
||||
]
|
||||
])
|
||||
expect(calls).toBe(0)
|
||||
expect(ownerCalls).toBe(0)
|
||||
expect(copiedOwners).toBe(0)
|
||||
expect(sizeCalls).toBe(0)
|
||||
expect(cwdCalls).toBe(0)
|
||||
})
|
||||
@@ -233,7 +233,7 @@ export class DaemonServer {
|
||||
}
|
||||
|
||||
private isIdle(): boolean {
|
||||
if (this.admission.inFlight > 0 || this.host.listSessions().length > 0) {
|
||||
if (this.admission.inFlight > 0 || this.host.hasLiveSessions()) {
|
||||
return false
|
||||
}
|
||||
if (this.endpoint.lost) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Terminal } from '@xterm/headless'
|
||||
import { bufferRows, cellDescriptor } from './serialize-grid-cell-descriptors'
|
||||
import { cellDescriptor, compareBufferRows } from './serialize-grid-cell-descriptors'
|
||||
import { createFuzzTerminal, writeTerminal } from './serialize-grid-roundtrip'
|
||||
|
||||
// Frozen allocating oracle from f69052e; a reused cell must preserve every descriptor.
|
||||
@@ -78,48 +78,6 @@ function allocatingBufferRows(
|
||||
}
|
||||
|
||||
describe('serialize oracle cell reuse', () => {
|
||||
it.each([false, true])(
|
||||
'matches allocating cells across SGR, wide text, buffers and resize (ConPTY=%s)',
|
||||
(conpty) => {
|
||||
const terminal = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
|
||||
try {
|
||||
const writes = [
|
||||
'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n',
|
||||
'\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n',
|
||||
'\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩💻é\r\n',
|
||||
'scroll1\r\nscroll2\r\nscroll3\r\n',
|
||||
'\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m',
|
||||
'\x1b[?1049l\x1b[2J\x1b[Hclear'
|
||||
]
|
||||
for (const data of writes) {
|
||||
writeTerminal(terminal, data)
|
||||
for (const buffer of [
|
||||
terminal.buffer.normal,
|
||||
terminal.buffer.alternate,
|
||||
terminal.buffer.active
|
||||
]) {
|
||||
expect(bufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)).toEqual(
|
||||
allocatingBufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)
|
||||
)
|
||||
}
|
||||
terminal.resize(terminal.cols === 14 ? 9 : 14, 4)
|
||||
expect(
|
||||
bufferRows(terminal.buffer.active, 0, terminal.buffer.active.length, terminal.cols)
|
||||
).toEqual(
|
||||
allocatingBufferRows(
|
||||
terminal.buffer.active,
|
||||
0,
|
||||
terminal.buffer.active.length,
|
||||
terminal.cols
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves the order of every text flag combination while reloading a plain cell', () => {
|
||||
const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
|
||||
try {
|
||||
@@ -167,50 +125,11 @@ describe('serialize oracle cell reuse', () => {
|
||||
const scratch = terminal.buffer.active.getNullCell()
|
||||
expect(cellDescriptor(line, 3, 4, scratch)).toBe('CLIPPED')
|
||||
expect(cellDescriptor(line, 3, 4, scratch)).toBe(allocatingCellDescriptor(line, 3, 4))
|
||||
expect(bufferRows(terminal.buffer.active, 0, 1, 4)).toEqual(
|
||||
allocatingBufferRows(terminal.buffer.active, 0, 1, 4)
|
||||
)
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('loads every cell into one traversal-local scratch object and retains immutable descriptors', () => {
|
||||
const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
|
||||
try {
|
||||
writeTerminal(terminal, '\x1b[1mA\x1b[0m B界')
|
||||
const buffer = terminal.buffer.active
|
||||
const scratch = buffer.getNullCell()
|
||||
const allocate = vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch)
|
||||
const getLine = buffer.getLine.bind(buffer)
|
||||
const loaded: unknown[] = []
|
||||
vi.spyOn(buffer, 'getLine').mockImplementation((y) => {
|
||||
const line = getLine(y)
|
||||
if (line) {
|
||||
const getCell = line.getCell.bind(line)
|
||||
vi.spyOn(line, 'getCell').mockImplementation((x, cell) => {
|
||||
loaded.push(cell)
|
||||
return getCell(x, cell)
|
||||
})
|
||||
}
|
||||
return line
|
||||
})
|
||||
const expected = allocatingBufferRows(buffer, 0, 2, 4)
|
||||
loaded.length = 0
|
||||
const actual = bufferRows(buffer, 0, 2, 4)
|
||||
expect(actual).toEqual(expected)
|
||||
expect(allocate).toHaveBeenCalledTimes(1)
|
||||
expect(loaded).toHaveLength(8)
|
||||
expect(loaded.every((cell) => cell === scratch)).toBe(true)
|
||||
writeTerminal(terminal, '\x1b[2J\x1b[Hnew')
|
||||
bufferRows(buffer, 0, 2, 4)
|
||||
expect(actual).toEqual(expected)
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps missing lines and invalid columns blank after a styled cell occupied the scratch', () => {
|
||||
const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
|
||||
try {
|
||||
@@ -230,3 +149,202 @@ describe('serialize oracle cell reuse', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function allocatingRowDiff(stage: string, expected: string[][], actual: string[][]) {
|
||||
for (let y = 0; y < Math.max(expected.length, actual.length); y++) {
|
||||
const expectedRow = expected[y]
|
||||
const actualRow = actual[y]
|
||||
if (
|
||||
!expectedRow ||
|
||||
!actualRow ||
|
||||
expectedRow.length !== actualRow.length ||
|
||||
!expectedRow.every(
|
||||
(cell, x) => cell === actualRow[x] || (cell === CLIPPED && actualRow[x]?.startsWith('▯'))
|
||||
)
|
||||
) {
|
||||
return { stage, row: y, expected: expectedRow?.join('|'), actual: actualRow?.join('|') }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function expectComparisonParity(
|
||||
expected: Buffer,
|
||||
actual: Buffer,
|
||||
cols: number,
|
||||
expectedStart = 0,
|
||||
expectedEnd = expected.length,
|
||||
actualStart = 0,
|
||||
actualEnd = actual.length
|
||||
): ReturnType<typeof compareBufferRows> {
|
||||
const frozen = allocatingRowDiff(
|
||||
'parity',
|
||||
allocatingBufferRows(expected, expectedStart, expectedEnd, cols),
|
||||
allocatingBufferRows(actual, actualStart, actualEnd, cols)
|
||||
)
|
||||
expect(
|
||||
compareBufferRows(
|
||||
'parity',
|
||||
expected,
|
||||
expectedStart,
|
||||
expectedEnd,
|
||||
actual,
|
||||
actualStart,
|
||||
actualEnd,
|
||||
cols
|
||||
)
|
||||
).toEqual(frozen)
|
||||
return frozen
|
||||
}
|
||||
|
||||
describe('serialize oracle streaming comparison', () => {
|
||||
it('reuses one cell per buffer and stops before rows after the first difference', () => {
|
||||
const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 })
|
||||
const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 })
|
||||
try {
|
||||
writeTerminal(source, 'first\r\nsecond\r\nthird\r\nlast')
|
||||
writeTerminal(replay, 'wrong\r\nsecond\r\nthird\r\nlast')
|
||||
for (const buffer of [source.buffer.active, replay.buffer.active]) {
|
||||
const scratch = buffer.getNullCell()
|
||||
vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch)
|
||||
const getLine = buffer.getLine.bind(buffer)
|
||||
vi.spyOn(buffer, 'getLine').mockImplementation((y) => {
|
||||
const line = getLine(y)
|
||||
if (line) {
|
||||
const getCell = line.getCell.bind(line)
|
||||
vi.spyOn(line, 'getCell').mockImplementation((x, cell) => {
|
||||
expect(cell).toBe(scratch)
|
||||
return getCell(x, cell)
|
||||
})
|
||||
}
|
||||
return line
|
||||
})
|
||||
}
|
||||
const diff = compareBufferRows(
|
||||
'visible-grid',
|
||||
source.buffer.active,
|
||||
0,
|
||||
4,
|
||||
replay.buffer.active,
|
||||
0,
|
||||
4,
|
||||
8
|
||||
)
|
||||
expect(diff?.row).toBe(0)
|
||||
for (const buffer of [source.buffer.active, replay.buffer.active]) {
|
||||
expect(buffer.getNullCell).toHaveBeenCalledTimes(1)
|
||||
expect(buffer.getLine).toHaveBeenCalledTimes(2)
|
||||
expect(buffer.getLine).toHaveBeenCalledWith(0)
|
||||
expect(buffer.getLine).toHaveBeenCalledWith(3)
|
||||
}
|
||||
writeTerminal(source, '\x1b[2J\x1b[Hchanged')
|
||||
expect(diff?.expected).toContain('f·w1·f0:-1·b0:-1·0000000')
|
||||
} finally {
|
||||
source.dispose()
|
||||
replay.dispose()
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves first-row diagnostics through buffer changes (ConPTY=%s)',
|
||||
(conpty) => {
|
||||
const source = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
|
||||
const replay = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
|
||||
try {
|
||||
for (const [index, data] of [
|
||||
'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n',
|
||||
'\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n',
|
||||
'\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩💻é\r\n',
|
||||
'scroll1\r\nscroll2\r\nscroll3\r\n',
|
||||
'\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m',
|
||||
'\x1b[?1049l\x1b[2J\x1b[Hclear'
|
||||
].entries()) {
|
||||
writeTerminal(source, data)
|
||||
writeTerminal(replay, data)
|
||||
for (const [expected, actual] of [
|
||||
[source.buffer.active, replay.buffer.active],
|
||||
[source.buffer.normal, replay.buffer.normal],
|
||||
[source.buffer.alternate, replay.buffer.alternate]
|
||||
]) {
|
||||
expect(expectComparisonParity(expected!, actual!, source.cols)).toBeNull()
|
||||
expectComparisonParity(expected!, actual!, source.cols + 2, -1, expected!.length + 1)
|
||||
}
|
||||
const corruption = `\x1b[H\x1b[0mFAULT${index}`
|
||||
writeTerminal(replay, corruption)
|
||||
expect(
|
||||
expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols)
|
||||
).not.toBeNull()
|
||||
writeTerminal(source, corruption)
|
||||
source.resize(source.cols === 14 ? 9 : 14, 4)
|
||||
replay.resize(source.cols, 4)
|
||||
expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols)
|
||||
}
|
||||
} finally {
|
||||
source.dispose()
|
||||
replay.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('detects every text-flag loss and compares all combinations against the allocating oracle', () => {
|
||||
const source = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
|
||||
const replay = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
|
||||
try {
|
||||
const sgr = [1, 2, 3, 4, 7, 8, 9]
|
||||
const writeFlags = (terminal: Terminal, mask: number): void => {
|
||||
const codes = sgr.filter((_code, bit) => (mask & (1 << bit)) !== 0)
|
||||
writeTerminal(terminal, `\x1b[H\x1b[0m\x1b[${codes.length ? codes.join(';') : 0}mA\x1b[0mB`)
|
||||
}
|
||||
for (let mask = 0; mask < 128; mask++) {
|
||||
writeFlags(source, mask)
|
||||
writeFlags(replay, mask)
|
||||
expect(expectComparisonParity(source.buffer.active, replay.buffer.active, 4)).toBeNull()
|
||||
for (let bit = 0; bit < sgr.length; bit++) {
|
||||
if ((mask & (1 << bit)) !== 0) {
|
||||
writeFlags(replay, mask & ~(1 << bit))
|
||||
expect(
|
||||
expectComparisonParity(source.buffer.active, replay.buffer.active, 4)
|
||||
).not.toBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
source.dispose()
|
||||
replay.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['abc界', 'abc ', 4, false],
|
||||
['abc界', 'abc\x1b[48;2;1;2;3m ', 4, false],
|
||||
['abc ', 'abc界', 4, true],
|
||||
['é', 'è', 8, true],
|
||||
['\x1b[32mA', '\x1b[38;5;2mA', 8, false],
|
||||
['\x1b[42m ', '\x1b[48;5;2m ', 8, false],
|
||||
['\x1b[7;31m ', '\x1b[7;32m ', 8, true],
|
||||
['\x1b[4m\x1b[2J', '\x1b[2J', 8, false],
|
||||
['\x1b[4m ', ' ', 8, true],
|
||||
['\x1b[4m \x1b[0mB', ' B', 8, true],
|
||||
['text\r\n', 'text', 8, false],
|
||||
['text\r\n\x1b[48;2;1;2;3m\x1b[2K', 'text', 8, true]
|
||||
] as const)(
|
||||
'preserves blank, clipped and color policy for %j / %j',
|
||||
(expected, actual, cols, differs) => {
|
||||
const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 })
|
||||
const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 })
|
||||
try {
|
||||
writeTerminal(source, expected)
|
||||
writeTerminal(replay, actual)
|
||||
expect(
|
||||
Boolean(expectComparisonParity(source.buffer.active, replay.buffer.active, cols))
|
||||
).toBe(differs)
|
||||
expectComparisonParity(source.buffer.active, replay.buffer.active, cols, -1, 6, -1, 5)
|
||||
expectComparisonParity(source.buffer.active, replay.buffer.active, cols, 1, 5, 0, 4)
|
||||
} finally {
|
||||
source.dispose()
|
||||
replay.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ export type GridDiff = { stage: string; row?: number; expected: unknown; actual:
|
||||
|
||||
type BufferLine = NonNullable<ReturnType<Terminal['buffer']['active']['getLine']>>
|
||||
type Buffer = Terminal['buffer']['active']
|
||||
type Cell = ReturnType<Buffer['getNullCell']>
|
||||
|
||||
const COLOR_MODE_P16 = 16777216
|
||||
const COLOR_MODE_P256 = 33554432
|
||||
@@ -64,33 +65,158 @@ function cellsMatch(expected: string, actual: string): boolean {
|
||||
return expected === actual || (expected === CLIPPED && actual.startsWith('▯'))
|
||||
}
|
||||
|
||||
function rowsMatch(expected: string[], actual: string[]): boolean {
|
||||
return expected.length === actual.length && expected.every((e, i) => cellsMatch(e, actual[i]!))
|
||||
function sameColor(
|
||||
expectedMode: number,
|
||||
expectedColor: number,
|
||||
actualMode: number,
|
||||
actualColor: number
|
||||
): boolean {
|
||||
return (
|
||||
expectedColor === actualColor &&
|
||||
canonicalColorMode(expectedMode, expectedColor) === canonicalColorMode(actualMode, actualColor)
|
||||
)
|
||||
}
|
||||
|
||||
export function bufferRows(buffer: Buffer, start: number, end: number, cols: number): string[][] {
|
||||
const rows: string[][] = []
|
||||
const reusableCell = buffer.getNullCell()
|
||||
for (let y = start; y < end; y++) {
|
||||
rows.push(rowCells(buffer.getLine(y), cols, reusableCell))
|
||||
// Equal public fields imply identical descriptors; differing fields still use the frozen policy.
|
||||
function sameCellFields(expected: Cell, actual: Cell): boolean {
|
||||
if (
|
||||
expected.getWidth() !== actual.getWidth() ||
|
||||
!sameColor(
|
||||
expected.getBgColorMode(),
|
||||
expected.getBgColor(),
|
||||
actual.getBgColorMode(),
|
||||
actual.getBgColor()
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) {
|
||||
rows.pop()
|
||||
const expectedChars = expected.getChars()
|
||||
const actualChars = actual.getChars()
|
||||
const expectedBlank = expectedChars === '' || expectedChars === ' '
|
||||
const actualBlank = actualChars === '' || actualChars === ' '
|
||||
if (expectedBlank || actualBlank) {
|
||||
return (
|
||||
expectedBlank &&
|
||||
actualBlank &&
|
||||
(expectedChars === ' ' && expected.isUnderline() !== 0) ===
|
||||
(actualChars === ' ' && actual.isUnderline() !== 0) &&
|
||||
(expectedChars === ' ' && expected.isStrikethrough() !== 0) ===
|
||||
(actualChars === ' ' && actual.isStrikethrough() !== 0) &&
|
||||
(expectedChars === ' ' && expected.isOverline() !== 0) ===
|
||||
(actualChars === ' ' && actual.isOverline() !== 0) &&
|
||||
(expected.isInverse() !== 0) === (actual.isInverse() !== 0) &&
|
||||
(expected.isInverse() === 0 ||
|
||||
sameColor(
|
||||
expected.getFgColorMode(),
|
||||
expected.getFgColor(),
|
||||
actual.getFgColorMode(),
|
||||
actual.getFgColor()
|
||||
))
|
||||
)
|
||||
}
|
||||
return rows
|
||||
return (
|
||||
expectedChars === actualChars &&
|
||||
sameColor(
|
||||
expected.getFgColorMode(),
|
||||
expected.getFgColor(),
|
||||
actual.getFgColorMode(),
|
||||
actual.getFgColor()
|
||||
) &&
|
||||
(expected.isBold() !== 0) === (actual.isBold() !== 0) &&
|
||||
(expected.isDim() !== 0) === (actual.isDim() !== 0) &&
|
||||
(expected.isItalic() !== 0) === (actual.isItalic() !== 0) &&
|
||||
(expected.isUnderline() !== 0) === (actual.isUnderline() !== 0) &&
|
||||
(expected.isInverse() !== 0) === (actual.isInverse() !== 0) &&
|
||||
(expected.isInvisible() !== 0) === (actual.isInvisible() !== 0) &&
|
||||
(expected.isStrikethrough() !== 0) === (actual.isStrikethrough() !== 0)
|
||||
)
|
||||
}
|
||||
|
||||
export function compareRowSets(
|
||||
function trimmedEnd(
|
||||
buffer: Buffer,
|
||||
start: number,
|
||||
end: number,
|
||||
cols: number,
|
||||
scratch: Cell
|
||||
): number {
|
||||
while (end > start) {
|
||||
const line = buffer.getLine(end - 1)
|
||||
let blank = true
|
||||
for (let x = 0; x < cols; x++) {
|
||||
if (cellDescriptor(line, x, cols, scratch) !== DEFAULT_BLANK) {
|
||||
blank = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!blank) {
|
||||
break
|
||||
}
|
||||
end--
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
function lineCellsMatch(
|
||||
expected: BufferLine | undefined,
|
||||
actual: BufferLine | undefined,
|
||||
cols: number,
|
||||
expectedScratch: Cell,
|
||||
actualScratch: Cell
|
||||
): boolean {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const expectedCell =
|
||||
expected && x < expected.length ? expected.getCell(x, expectedScratch) : null
|
||||
const actualCell = actual && x < actual.length ? actual.getCell(x, actualScratch) : null
|
||||
const clipped =
|
||||
x === cols - 1 &&
|
||||
((expected && expected.length > cols && (expectedCell?.getWidth() ?? 0) > 1) ||
|
||||
(actual && actual.length > cols && (actualCell?.getWidth() ?? 0) > 1))
|
||||
if (expectedCell && actualCell && !clipped && sameCellFields(expectedCell, actualCell)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!cellsMatch(
|
||||
cellDescriptor(expected, x, cols, expectedScratch),
|
||||
cellDescriptor(actual, x, cols, actualScratch)
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Compares in place and formats only the first differing row, preserving diagnostic bytes. */
|
||||
export function compareBufferRows(
|
||||
stage: string,
|
||||
expected: string[][],
|
||||
actual: string[][]
|
||||
expected: Buffer,
|
||||
expectedStart: number,
|
||||
expectedEnd: number,
|
||||
actual: Buffer,
|
||||
actualStart: number,
|
||||
actualEnd: number,
|
||||
cols: number
|
||||
): GridDiff | null {
|
||||
const length = Math.max(expected.length, actual.length)
|
||||
for (let y = 0; y < length; y++) {
|
||||
const e = expected[y]
|
||||
const a = actual[y]
|
||||
if (!e || !a || !rowsMatch(e, a)) {
|
||||
return { stage, row: y, expected: e?.join('|'), actual: a?.join('|') }
|
||||
const expectedScratch = expected.getNullCell()
|
||||
const actualScratch = actual.getNullCell()
|
||||
const expectedLength =
|
||||
trimmedEnd(expected, expectedStart, expectedEnd, cols, expectedScratch) - expectedStart
|
||||
const actualLength = trimmedEnd(actual, actualStart, actualEnd, cols, actualScratch) - actualStart
|
||||
for (let y = 0; y < Math.max(expectedLength, actualLength); y++) {
|
||||
const expectedLine = expected.getLine(expectedStart + y)
|
||||
const actualLine = actual.getLine(actualStart + y)
|
||||
if (
|
||||
y >= expectedLength ||
|
||||
y >= actualLength ||
|
||||
!lineCellsMatch(expectedLine, actualLine, cols, expectedScratch, actualScratch)
|
||||
) {
|
||||
return {
|
||||
stage,
|
||||
row: y,
|
||||
expected:
|
||||
y < expectedLength ? rowCells(expectedLine, cols, expectedScratch).join('|') : undefined,
|
||||
actual: y < actualLength ? rowCells(actualLine, cols, actualScratch).join('|') : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -18,12 +18,7 @@ import {
|
||||
variantTrailingBackgroundRows
|
||||
} from './serialize-grid-variant-scope'
|
||||
import type { GridDiff } from './serialize-grid-cell-descriptors'
|
||||
import {
|
||||
bufferRows,
|
||||
cellDescriptor,
|
||||
CLIPPED,
|
||||
compareRowSets
|
||||
} from './serialize-grid-cell-descriptors'
|
||||
import { cellDescriptor, CLIPPED, compareBufferRows } from './serialize-grid-cell-descriptors'
|
||||
|
||||
export type NamedSerializer = { name: string; create: () => SerializeAddon }
|
||||
|
||||
@@ -156,15 +151,25 @@ async function compareReplay(
|
||||
(src.type === dst.type
|
||||
? null
|
||||
: { stage: 'active-buffer', expected: src.type, actual: dst.type }) ??
|
||||
compareRowSets(
|
||||
compareBufferRows(
|
||||
'visible-grid',
|
||||
bufferRows(src, src.baseY, src.baseY + rows, cols),
|
||||
bufferRows(dst, dst.baseY, dst.baseY + rows, cols)
|
||||
src,
|
||||
src.baseY,
|
||||
src.baseY + rows,
|
||||
dst,
|
||||
dst.baseY,
|
||||
dst.baseY + rows,
|
||||
cols
|
||||
) ??
|
||||
compareRowSets(
|
||||
compareBufferRows(
|
||||
'normal-buffer',
|
||||
bufferRows(source.buffer.normal, normalStart, source.buffer.normal.length, cols),
|
||||
bufferRows(replay.buffer.normal, 0, replay.buffer.normal.length, cols)
|
||||
source.buffer.normal,
|
||||
normalStart,
|
||||
source.buffer.normal.length,
|
||||
replay.buffer.normal,
|
||||
0,
|
||||
replay.buffer.normal.length,
|
||||
cols
|
||||
) ??
|
||||
(src.cursorX === dst.cursorX && src.cursorY === dst.cursorY
|
||||
? null
|
||||
|
||||
@@ -253,7 +253,13 @@ describe('serialize round trip over captured PTY transcripts', () => {
|
||||
'jitter/true/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63'
|
||||
])
|
||||
}
|
||||
if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) {
|
||||
// The pre-#22586 serializer has these same row-1 bold diffs on the untouched capture.
|
||||
if (SEEDS === 2 && transcript.name === 'dsb-6-9-0-folder') {
|
||||
expect(failureSignatures).toEqual([
|
||||
'jitter/false/2/19:2050835176269320209c56be5ae9e525424635dfca511d44d3911d3a22ba9d27',
|
||||
'jitter/true/2/19:2050835176269320209c56be5ae9e525424635dfca511d44d3911d3a22ba9d27'
|
||||
])
|
||||
} else if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) {
|
||||
expect(failureSignatures).toEqual(FREEBUFF_BASELINE[transcript.name] ?? [])
|
||||
} else if (!OLD_ADDON_PATH && SEEDS === 2) {
|
||||
expect(counts['new-fail'] ?? 0).toBe(KNOWN_PREEXISTING_I2_FAILURES[transcript.name] ?? 0)
|
||||
|
||||
@@ -2,8 +2,11 @@ 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 { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile'
|
||||
import { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile'
|
||||
import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile'
|
||||
import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path'
|
||||
import {
|
||||
OVERLAY_ONLY_FEATURES,
|
||||
STARTUP_COMMAND_FEATURES
|
||||
@@ -57,6 +60,45 @@ describePosix('daemon shell-ready bash wrapper', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
itWithBash.each([
|
||||
['daemon', getDaemonBashShellReadyRcfileContent],
|
||||
['local', getBashShellReadyRcfileContent]
|
||||
] as const)('keeps this app CLI first after %s Bash profiles reset PATH', (_lane, content) => {
|
||||
const cliBin = join(userDataPath, 'cli', 'bin')
|
||||
const ambientBin = join(userDataPath, 'ambient-bin')
|
||||
mkdirSync(cliBin, { recursive: true })
|
||||
mkdirSync(ambientBin)
|
||||
for (const bin of [cliBin, ambientBin]) {
|
||||
const launcher = join(bin, 'orca-dev')
|
||||
writeFileSync(launcher, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(launcher, 0o755)
|
||||
}
|
||||
const env: Record<string, string> = {
|
||||
HOME: userDataPath,
|
||||
USERPROFILE: userDataPath,
|
||||
PATH: `${ambientBin}:/usr/bin:/bin`,
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
}
|
||||
const expectedLauncher = prependOrcaCliDirToChildPath(env, {
|
||||
isPackaged: false,
|
||||
userDataPath
|
||||
})
|
||||
writeFileSync(
|
||||
join(userDataPath, '.bash_profile'),
|
||||
'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n'
|
||||
)
|
||||
const rcfile = join(userDataPath, 'cli-path-rcfile')
|
||||
writeFileSync(rcfile, content())
|
||||
const result = spawnSync('bash', ['-c', '. "$1"; command -v orca-dev', 'bash', rcfile], {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 5000
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout.split('\x1b]133;C\x07').join('').trim()).toBe(expectedLauncher)
|
||||
})
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -9,7 +9,96 @@ function writeSync(terminal: Terminal, data: string): void {
|
||||
core.writeSync(data)
|
||||
}
|
||||
|
||||
type Cell = ReturnType<Terminal['buffer']['active']['getNullCell']>
|
||||
|
||||
function observeCellReads(terminal: Terminal) {
|
||||
const allocated: Cell[] = []
|
||||
const targets: (Cell | undefined)[] = []
|
||||
const active = terminal.buffer.active
|
||||
const source = {
|
||||
rows: terminal.rows,
|
||||
modes: terminal.modes,
|
||||
buffer: {
|
||||
active: {
|
||||
baseY: active.baseY,
|
||||
cursorX: active.cursorX,
|
||||
cursorY: active.cursorY,
|
||||
viewportY: active.viewportY,
|
||||
getNullCell: () => {
|
||||
const cell = active.getNullCell()
|
||||
allocated.push(cell)
|
||||
return cell
|
||||
},
|
||||
getLine: (row: number) => {
|
||||
const line = active.getLine(row)
|
||||
if (!line) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
isWrapped: line.isWrapped,
|
||||
length: line.length,
|
||||
translateToString: line.translateToString.bind(line),
|
||||
getCell: (column: number, reusableCell?: Cell) => {
|
||||
targets.push(reusableCell)
|
||||
return line.getCell(column, reusableCell)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { source, allocated, targets }
|
||||
}
|
||||
|
||||
describe('readTerminalCursorLineContext', () => {
|
||||
it('reuses one cell across every scan without retaining it between reads', () => {
|
||||
const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
|
||||
try {
|
||||
writeSync(terminal, '\x1b[1m❯\x1b[22m 界e\u0301\x1b7\r\n\x1b[31mfooter\x1b8')
|
||||
const rig = observeCellReads(terminal)
|
||||
const first = readTerminalCursorLineContext(rig.source, terminal.rows)
|
||||
expect(rig.allocated).toHaveLength(1)
|
||||
expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2)
|
||||
expect(rig.targets.every((cell) => cell === rig.allocated[0])).toBe(true)
|
||||
rig.targets.length = 0
|
||||
expect(readTerminalCursorLineContext(rig.source, terminal.rows)).toEqual(first)
|
||||
expect(rig.allocated).toHaveLength(2)
|
||||
expect(rig.allocated[1]).not.toBe(rig.allocated[0])
|
||||
expect(rig.targets.every((cell) => cell === rig.allocated[1])).toBe(true)
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves full context for an adapter without reusable cells', () => {
|
||||
const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
|
||||
try {
|
||||
writeSync(
|
||||
terminal,
|
||||
'\x1b[1m❯\x1b[22m typed\x1b7\x1b[2m hint\x1b[22m\r\n\x1b[38;2;1;2;3m界e\u0301\x1b[0m\x1b8'
|
||||
)
|
||||
const rig = observeCellReads(terminal)
|
||||
const source = {
|
||||
...rig.source,
|
||||
buffer: { active: { ...rig.source.buffer.active, getNullCell: undefined } }
|
||||
}
|
||||
const context = readTerminalCursorLineContext(source, terminal.rows)
|
||||
expect(context).toEqual(readTerminalCursorLineContext(terminal, terminal.rows))
|
||||
expect(context?.typedRows).toEqual(['❯ typed'])
|
||||
expect(context?.typedRowsBelow[0]).toBe('界e\u0301')
|
||||
expect(context?.beforeCursor).toBe('❯ typed')
|
||||
expect(context?.afterCursor).toBe('')
|
||||
expect(context?.rawAfterCursor).toBe(' hint')
|
||||
expect(context?.promptGlyphBoldRows).toEqual([true])
|
||||
expect(context?.rowsBelowCustomForeground?.[0]).toBe(true)
|
||||
expect(rig.allocated).toEqual([])
|
||||
expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2)
|
||||
expect(rig.targets.every((cell) => cell === undefined)).toBe(true)
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' },
|
||||
{ cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' }
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import type { Session } from './session'
|
||||
import {
|
||||
SessionNotFoundError,
|
||||
type SessionInfo,
|
||||
type TakePendingOutputResult,
|
||||
type TerminalSnapshot
|
||||
} from './types'
|
||||
import { SessionNotFoundError } from './types'
|
||||
import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types'
|
||||
import type { CreateOrAttachResult } from './terminal-host-create-contract'
|
||||
import type { TerminalHostOptions } from './terminal-host-options'
|
||||
import { disposeTerminalHostSessions } from './terminal-host-disposal'
|
||||
import { getAliveTerminalHostSession } from './terminal-host-session-access'
|
||||
import { TerminalSessionTeardown } from './terminal-session-teardown'
|
||||
import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
|
||||
import {
|
||||
createOrAttachClaimedAgentSession,
|
||||
type InternalCreateOrAttachOptions
|
||||
} from './terminal-host-agent-session-claim'
|
||||
import { createOrAttachClaimedAgentSession } from './terminal-host-agent-session-claim'
|
||||
import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim'
|
||||
import { TerminalHostAgentSessionGenerations } from './terminal-host-agent-session-generations'
|
||||
import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd'
|
||||
import { TerminalHostTombstones } from './terminal-host-tombstones'
|
||||
@@ -24,10 +18,8 @@ import { TerminalAttachCanceledError } from './daemon-errors'
|
||||
import { waitForTerminalAttachOperation } from './terminal-attach-cancellation'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { pruneRetiredPtyIncarnations } from '../../shared/retired-pty-incarnations'
|
||||
import {
|
||||
inspectTerminalHostProcess,
|
||||
type TerminalHostProcessInspection
|
||||
} from './terminal-host-process-inspection'
|
||||
import { inspectTerminalHostProcess } from './terminal-host-process-inspection'
|
||||
import type { TerminalHostProcessInspection } from './terminal-host-process-inspection'
|
||||
import {
|
||||
confirmTerminalHostForegroundProcess,
|
||||
confirmTerminalHostShellForeground,
|
||||
@@ -320,6 +312,15 @@ export class TerminalHost {
|
||||
return listLiveTerminalHostSessions(this.sessions, this.agentSessionOwners)
|
||||
}
|
||||
|
||||
hasLiveSessions(): boolean {
|
||||
let hasLive = false
|
||||
// Read every Session, even after a live one, to preserve the inventory's error order.
|
||||
for (const session of this.sessions.values()) {
|
||||
hasLive = session.isAlive || hasLive
|
||||
}
|
||||
return hasLive
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
this.creationFenced = true
|
||||
if (this.disposePromise) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execFile, type ChildProcess, type ExecFileOptions } from 'node:child_pr
|
||||
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
|
||||
import { endSubprocessStdin } from '../../../shared/subprocess-stdin-write'
|
||||
import { runProcess } from '../../../shared/child-process/run-process'
|
||||
import { resolveSelectedLocalCommand } from '../../ipc/command-path-resolver'
|
||||
import type { WslProcessGroupTermination } from '../wsl-process-group-termination'
|
||||
import { createAbortError } from './abort-error'
|
||||
import { killSpawnedCommandTree } from './spawned-command-tree-kill'
|
||||
@@ -30,7 +31,10 @@ export async function execFileCaptureToTermination(
|
||||
// Spawn cost is reported by spawnProcess's observer, which runProcess goes
|
||||
// through; recording it again here would double-count every capture.
|
||||
const pending = runProcess({
|
||||
program: command,
|
||||
program: resolveSelectedLocalCommand(command, {
|
||||
env: options.env,
|
||||
cwd: typeof options.cwd === 'string' ? options.cwd : undefined
|
||||
}),
|
||||
args,
|
||||
cwd: typeof options.cwd === 'string' ? options.cwd : undefined,
|
||||
env: options.env,
|
||||
|
||||
@@ -1,250 +1,183 @@
|
||||
/**
|
||||
* POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path.
|
||||
* Timing distributions are reported for field comparison; CI assertions stay structural.
|
||||
*/
|
||||
import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { existsSync, watch } from 'node:fs'
|
||||
import { chmod, mkdir, readdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '../../../shared/child-process/run-process'
|
||||
import { gitExecFileAsync } from './git-exec-file'
|
||||
import {
|
||||
GIT_ADMISSION_AGING_MS,
|
||||
GENERAL_CAP,
|
||||
GENERAL_HEADROOM,
|
||||
GitAdmissionScheduler,
|
||||
MAX_GIT_CHILDREN,
|
||||
_gitAdmissionSnapshotForTests,
|
||||
_resetGitAdmissionForTests,
|
||||
type GitAdmissionEvent
|
||||
} from './git-subprocess-admission'
|
||||
import {
|
||||
assertAdmissionLedger,
|
||||
createStormRoot,
|
||||
formatMeasurementTable,
|
||||
measureStorm,
|
||||
type InteractiveQueueSnapshot
|
||||
} from './git-admission-storm-test-fixture'
|
||||
|
||||
type StormMeasurement = {
|
||||
mode: 'disabled' | 'enabled'
|
||||
maxConcurrentChildren: number
|
||||
eventLoopMaxDriftMs: number
|
||||
eventLoopP99DriftMs: number
|
||||
interactiveP50Ms: number
|
||||
interactiveP95Ms: number
|
||||
totalWallMs: number
|
||||
admissionEvents: GitAdmissionEvent[]
|
||||
interactiveQueueSnapshots: InteractiveQueueSnapshot[]
|
||||
function waitForLiveChild(stateDir: string, id: string, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const watcher = watch(stateDir)
|
||||
const finish = (error?: Error): void => {
|
||||
clearTimeout(deadline)
|
||||
watcher.close()
|
||||
signal.removeEventListener('abort', aborted)
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
const aborted = (): void => finish(new Error(`Aborted waiting for ${id}`))
|
||||
const deadline = setTimeout(() => finish(new Error(`Child ${id} did not start`)), 5_000)
|
||||
const check = (): void => {
|
||||
if (existsSync(path.join(stateDir, `${id}.live`))) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
watcher.on('change', check)
|
||||
watcher.once('error', finish)
|
||||
signal.addEventListener('abort', aborted, { once: true })
|
||||
if (signal.aborted) {
|
||||
aborted()
|
||||
} else {
|
||||
check()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type InteractiveQueueSnapshot = {
|
||||
commandLabel: string
|
||||
backgroundWaiterIds: number[]
|
||||
}
|
||||
|
||||
const tempRoots: string[] = []
|
||||
const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalAdmissionDisabled === undefined) {
|
||||
delete process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
} else {
|
||||
process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled
|
||||
}
|
||||
_resetGitAdmissionForTests()
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
function percentile(values: readonly number[], percentileValue: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)]
|
||||
}
|
||||
|
||||
async function liveChildCount(stateDir: string): Promise<number> {
|
||||
return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length
|
||||
}
|
||||
|
||||
async function createStubGit(root: string): Promise<string> {
|
||||
async function verifySpawnContention(): Promise<void> {
|
||||
delete process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
const admissionEvents: GitAdmissionEvent[] = []
|
||||
_resetGitAdmissionForTests(
|
||||
new GitAdmissionScheduler({
|
||||
now: () => 0,
|
||||
onAdmissionEvent: (event) => admissionEvents.push(event)
|
||||
})
|
||||
)
|
||||
const root = await createStormRoot('contention')
|
||||
const stateDir = path.join(root, 'state')
|
||||
const gateDir = path.join(root, 'gates')
|
||||
const binDir = path.join(root, 'bin')
|
||||
await mkdir(binDir)
|
||||
const stubPath = path.join(binDir, 'git')
|
||||
await Promise.all([stateDir, gateDir, binDir].map((directory) => mkdir(directory)))
|
||||
const backgroundIds = Array.from({ length: GENERAL_CAP + 4 }, (_, index) => `background-${index}`)
|
||||
const headroomIds = Array.from({ length: GENERAL_HEADROOM }, (_, index) => `interactive-${index}`)
|
||||
const queuedIds = ['interactive-queued-0', 'interactive-queued-1']
|
||||
const ids = [...backgroundIds, ...headroomIds, ...queuedIds]
|
||||
const gates = await runProcess({
|
||||
program: 'mkfifo',
|
||||
args: ids.map((id) => path.join(gateDir, id))
|
||||
})
|
||||
expect(gates.code, gates.stderr).toBe(0)
|
||||
const stub = path.join(binDir, 'git')
|
||||
await writeFile(
|
||||
stubPath,
|
||||
stub,
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live"
|
||||
: > "$live"
|
||||
trap 'rm -f "$live"' EXIT HUP INT TERM
|
||||
sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")"
|
||||
IFS= read -r release < "$ORCA_STUB_GATE_DIR/$ORCA_STUB_ID"
|
||||
printf 'stub:%s\\n' "$*"
|
||||
`
|
||||
)
|
||||
await chmod(stubPath, 0o755)
|
||||
return binDir
|
||||
}
|
||||
|
||||
function setAdmissionMode(mode: StormMeasurement['mode']): void {
|
||||
if (mode === 'disabled') {
|
||||
process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
|
||||
} else {
|
||||
delete process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
}
|
||||
}
|
||||
|
||||
async function measureStorm(mode: StormMeasurement['mode']): Promise<StormMeasurement> {
|
||||
setAdmissionMode(mode)
|
||||
const admissionEvents: GitAdmissionEvent[] = []
|
||||
const admissionClockStartedAt = performance.now()
|
||||
_resetGitAdmissionForTests(
|
||||
new GitAdmissionScheduler({
|
||||
now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1),
|
||||
onAdmissionEvent: (event) => admissionEvents.push(event)
|
||||
})
|
||||
)
|
||||
const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${mode}-`))
|
||||
tempRoots.push(root)
|
||||
const stateDir = path.join(root, 'state')
|
||||
await mkdir(stateDir)
|
||||
const binDir = await createStubGit(root)
|
||||
const repoDirs = await Promise.all(
|
||||
Array.from({ length: 6 }, async (_, index) => {
|
||||
const repoDir = path.join(root, `repo-${index}`)
|
||||
await mkdir(repoDir)
|
||||
return repoDir
|
||||
})
|
||||
)
|
||||
const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
|
||||
const startedAt = performance.now()
|
||||
const drifts: number[] = []
|
||||
let nextJankSample = performance.now() + 50
|
||||
const jankTimer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
drifts.push(Math.max(0, now - nextJankSample))
|
||||
nextJankSample = now + 50
|
||||
}, 50)
|
||||
let maxConcurrentChildren = 0
|
||||
const censusTimer = setInterval(() => {
|
||||
void liveChildCount(stateDir).then((count) => {
|
||||
maxConcurrentChildren = Math.max(maxConcurrentChildren, count)
|
||||
})
|
||||
}, 5)
|
||||
|
||||
const background = Array.from({ length: 60 }, (_, index) =>
|
||||
gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], {
|
||||
cwd: repoDirs[index % repoDirs.length],
|
||||
env: {
|
||||
...baseEnv,
|
||||
ORCA_STUB_ID: `background-${index}`,
|
||||
ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200',
|
||||
ORCA_STUB_STATE_DIR: stateDir
|
||||
},
|
||||
admissionTier: 'background'
|
||||
})
|
||||
)
|
||||
await chmod(stub, 0o755)
|
||||
const controller = new AbortController()
|
||||
const commands = new Map<string, ReturnType<typeof gitExecFileAsync>>()
|
||||
const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = []
|
||||
const interactiveLatencies = await Promise.all(
|
||||
Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) =>
|
||||
new Promise<number>((resolve, reject) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
void (async () => {
|
||||
const concurrentAtInjection = await liveChildCount(stateDir)
|
||||
const commandStartedAt = performance.now()
|
||||
const commandLabel = `interactive-${index}`
|
||||
interactiveQueueSnapshots.push({
|
||||
commandLabel,
|
||||
backgroundWaiterIds: _gitAdmissionSnapshotForTests()
|
||||
.queuedWaiters.filter((waiter) => waiter.tier === 'background')
|
||||
.map((waiter) => waiter.id)
|
||||
})
|
||||
await gitExecFileAsync(['rev-parse', commandLabel], {
|
||||
cwd: repoDirs[index % repoDirs.length],
|
||||
env: {
|
||||
...baseEnv,
|
||||
ORCA_STUB_ID: `interactive-${index}`,
|
||||
ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)),
|
||||
ORCA_STUB_STATE_DIR: stateDir
|
||||
},
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
resolve(performance.now() - commandStartedAt)
|
||||
})().catch(reject)
|
||||
},
|
||||
50 * (index + 1)
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
await Promise.all(background)
|
||||
clearInterval(censusTimer)
|
||||
clearInterval(jankTimer)
|
||||
maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir))
|
||||
const totalWallMs = performance.now() - startedAt
|
||||
return {
|
||||
mode,
|
||||
maxConcurrentChildren,
|
||||
eventLoopMaxDriftMs: Math.max(0, ...drifts),
|
||||
eventLoopP99DriftMs: percentile(drifts, 0.99),
|
||||
interactiveP50Ms: percentile(interactiveLatencies, 0.5),
|
||||
interactiveP95Ms: percentile(interactiveLatencies, 0.95),
|
||||
totalWallMs,
|
||||
admissionEvents,
|
||||
interactiveQueueSnapshots
|
||||
}
|
||||
}
|
||||
|
||||
function formatMeasurementTable(rows: readonly StormMeasurement[]): string {
|
||||
const rounded = rows.map((row) => ({
|
||||
mode: row.mode,
|
||||
maxConcurrentChildren: row.maxConcurrentChildren,
|
||||
eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1),
|
||||
eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1),
|
||||
interactiveP50Ms: row.interactiveP50Ms.toFixed(1),
|
||||
interactiveP95Ms: row.interactiveP95Ms.toFixed(1),
|
||||
totalWallMs: row.totalWallMs.toFixed(1)
|
||||
}))
|
||||
return JSON.stringify(rounded)
|
||||
}
|
||||
|
||||
function assertAdmissionLedger(measurement: StormMeasurement): void {
|
||||
const activeWaiters = new Set<number>()
|
||||
const grantSequenceByWaiter = new Map<number, number>()
|
||||
const grantByLabel = new Map<string, GitAdmissionEvent>()
|
||||
|
||||
measurement.admissionEvents.forEach((event, index) => {
|
||||
expect(event.sequence).toBe(index)
|
||||
if (event.phase === 'grant') {
|
||||
activeWaiters.add(event.waiterId)
|
||||
grantSequenceByWaiter.set(event.waiterId, event.sequence)
|
||||
const label = event.args.find((arg) => arg.startsWith('interactive-'))
|
||||
if (label) {
|
||||
grantByLabel.set(label, event)
|
||||
const start = (id: string, tier: 'background' | 'interactive'): void => {
|
||||
if (tier === 'interactive') {
|
||||
interactiveQueueSnapshots.push({
|
||||
commandLabel: id,
|
||||
backgroundWaiterIds: _gitAdmissionSnapshotForTests()
|
||||
.queuedWaiters.filter((waiter) => waiter.tier === 'background')
|
||||
.map((waiter) => waiter.id)
|
||||
})
|
||||
}
|
||||
const command = gitExecFileAsync([tier === 'background' ? 'status' : 'rev-parse', id], {
|
||||
cwd: root,
|
||||
admissionTier: tier,
|
||||
signal: controller.signal,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
||||
ORCA_STUB_ID: id,
|
||||
ORCA_STUB_STATE_DIR: stateDir,
|
||||
ORCA_STUB_GATE_DIR: gateDir
|
||||
}
|
||||
} else {
|
||||
expect(activeWaiters.delete(event.waiterId)).toBe(true)
|
||||
})
|
||||
void command.catch(() => {})
|
||||
commands.set(id, command)
|
||||
}
|
||||
const release = async (id: string): Promise<void> => {
|
||||
await waitForLiveChild(stateDir, id, controller.signal)
|
||||
await writeFile(path.join(gateDir, id), 'release\n')
|
||||
await expect(commands.get(id)).resolves.toEqual({
|
||||
stdout: `stub:${id.startsWith('background-') ? 'status' : 'rev-parse'} ${id}\n`,
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
try {
|
||||
backgroundIds.forEach((id) => start(id, 'background'))
|
||||
expect(_gitAdmissionSnapshotForTests().queued).toBe(4)
|
||||
headroomIds.forEach((id) => start(id, 'interactive'))
|
||||
queuedIds.forEach((id) => start(id, 'interactive'))
|
||||
expect(_gitAdmissionSnapshotForTests()).toMatchObject({
|
||||
queued: 6,
|
||||
budgets: { general: { baseUsed: GENERAL_CAP, headroomUsed: GENERAL_HEADROOM } }
|
||||
})
|
||||
await Promise.all(
|
||||
[...backgroundIds.slice(0, GENERAL_CAP), ...headroomIds].map((id) =>
|
||||
waitForLiveChild(stateDir, id, controller.signal)
|
||||
)
|
||||
)
|
||||
expect(await readdir(stateDir)).toHaveLength(GENERAL_CAP + GENERAL_HEADROOM)
|
||||
for (const [index, id] of queuedIds.entries()) {
|
||||
await release(backgroundIds[index])
|
||||
await waitForLiveChild(stateDir, id, controller.signal)
|
||||
}
|
||||
expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN)
|
||||
for (const budget of event.budgets) {
|
||||
expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity)
|
||||
expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity)
|
||||
expect(_gitAdmissionSnapshotForTests().queuedWaiters.map((waiter) => waiter.tier)).toEqual([
|
||||
'background',
|
||||
'background',
|
||||
'background',
|
||||
'background'
|
||||
])
|
||||
for (const id of [...headroomIds, ...queuedIds, ...backgroundIds.slice(queuedIds.length)]) {
|
||||
await release(id)
|
||||
}
|
||||
})
|
||||
expect(activeWaiters.size).toBe(0)
|
||||
|
||||
for (const snapshot of measurement.interactiveQueueSnapshots) {
|
||||
const interactiveGrant = grantByLabel.get(snapshot.commandLabel)
|
||||
expect(interactiveGrant).toBeDefined()
|
||||
const preceded = snapshot.backgroundWaiterIds.filter(
|
||||
(waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity)
|
||||
).length
|
||||
expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9))
|
||||
expect(admissionEvents.filter((event) => event.phase === 'grant')).toHaveLength(ids.length)
|
||||
expect(admissionEvents.filter((event) => event.phase === 'release')).toHaveLength(ids.length)
|
||||
assertAdmissionLedger({ admissionEvents, interactiveQueueSnapshots })
|
||||
expect(_gitAdmissionSnapshotForTests()).toMatchObject({
|
||||
queued: 0,
|
||||
budgets: { general: { baseUsed: 0, headroomUsed: 0 } }
|
||||
})
|
||||
expect(await readdir(stateDir)).toEqual([])
|
||||
} finally {
|
||||
controller.abort()
|
||||
await Promise.allSettled(commands.values())
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('git admission storm measurement', () => {
|
||||
it('reports bounded-concurrency before and after measurements', async () => {
|
||||
const disabled = await measureStorm('disabled')
|
||||
const enabled = await measureStorm('enabled')
|
||||
console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`)
|
||||
assertAdmissionLedger(enabled)
|
||||
})
|
||||
it(
|
||||
'bounds real children and drains interactive work before the background backlog',
|
||||
verifySpawnContention
|
||||
)
|
||||
|
||||
// Output parity lives in git-admission-output-parity.test.ts: it needs real git,
|
||||
// not the PATH stub, so it runs on win32 too and cannot share this file's gate.
|
||||
it.runIf(process.env.ORCA_GIT_ADMISSION_STORM_MEASUREMENT === '1')(
|
||||
'reports bounded-concurrency before and after measurements',
|
||||
async () => {
|
||||
const disabled = await measureStorm('disabled')
|
||||
const enabled = await measureStorm('enabled')
|
||||
console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`)
|
||||
assertAdmissionLedger(enabled)
|
||||
}
|
||||
)
|
||||
// Real-git output parity remains in git-admission-output-parity.test.ts, including Windows.
|
||||
})
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path.
|
||||
* Timing distributions are reported for field comparison; CI assertions stay structural.
|
||||
*/
|
||||
import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, expect } from 'vitest'
|
||||
import { gitExecFileAsync } from './git-exec-file'
|
||||
import {
|
||||
GIT_ADMISSION_AGING_MS,
|
||||
GitAdmissionScheduler,
|
||||
MAX_GIT_CHILDREN,
|
||||
_gitAdmissionSnapshotForTests,
|
||||
_resetGitAdmissionForTests,
|
||||
type GitAdmissionEvent
|
||||
} from './git-subprocess-admission'
|
||||
|
||||
type StormMeasurement = {
|
||||
mode: 'disabled' | 'enabled'
|
||||
maxConcurrentChildren: number
|
||||
eventLoopMaxDriftMs: number
|
||||
eventLoopP99DriftMs: number
|
||||
interactiveP50Ms: number
|
||||
interactiveP95Ms: number
|
||||
totalWallMs: number
|
||||
admissionEvents: GitAdmissionEvent[]
|
||||
interactiveQueueSnapshots: InteractiveQueueSnapshot[]
|
||||
}
|
||||
|
||||
export type InteractiveQueueSnapshot = {
|
||||
commandLabel: string
|
||||
backgroundWaiterIds: number[]
|
||||
}
|
||||
|
||||
const tempRoots: string[] = []
|
||||
const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalAdmissionDisabled === undefined) {
|
||||
delete process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
} else {
|
||||
process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled
|
||||
}
|
||||
_resetGitAdmissionForTests()
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
export async function createStormRoot(label: string): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${label}-`))
|
||||
tempRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function percentile(values: readonly number[], percentileValue: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)]
|
||||
}
|
||||
|
||||
async function liveChildCount(stateDir: string): Promise<number> {
|
||||
return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length
|
||||
}
|
||||
|
||||
async function createStubGit(root: string): Promise<string> {
|
||||
const binDir = path.join(root, 'bin')
|
||||
await mkdir(binDir)
|
||||
const stubPath = path.join(binDir, 'git')
|
||||
await writeFile(
|
||||
stubPath,
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live"
|
||||
: > "$live"
|
||||
trap 'rm -f "$live"' EXIT HUP INT TERM
|
||||
sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")"
|
||||
printf 'stub:%s\\n' "$*"
|
||||
`
|
||||
)
|
||||
await chmod(stubPath, 0o755)
|
||||
return binDir
|
||||
}
|
||||
|
||||
function setAdmissionMode(mode: StormMeasurement['mode']): void {
|
||||
if (mode === 'disabled') {
|
||||
process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
|
||||
} else {
|
||||
delete process.env.ORCA_GIT_ADMISSION_DISABLED
|
||||
}
|
||||
}
|
||||
|
||||
export async function measureStorm(mode: StormMeasurement['mode']): Promise<StormMeasurement> {
|
||||
setAdmissionMode(mode)
|
||||
const admissionEvents: GitAdmissionEvent[] = []
|
||||
const admissionClockStartedAt = performance.now()
|
||||
_resetGitAdmissionForTests(
|
||||
new GitAdmissionScheduler({
|
||||
now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1),
|
||||
onAdmissionEvent: (event) => admissionEvents.push(event)
|
||||
})
|
||||
)
|
||||
const root = await createStormRoot(mode)
|
||||
const stateDir = path.join(root, 'state')
|
||||
await mkdir(stateDir)
|
||||
const binDir = await createStubGit(root)
|
||||
const repoDirs = await Promise.all(
|
||||
Array.from({ length: 6 }, async (_, index) => {
|
||||
const repoDir = path.join(root, `repo-${index}`)
|
||||
await mkdir(repoDir)
|
||||
return repoDir
|
||||
})
|
||||
)
|
||||
const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
|
||||
const startedAt = performance.now()
|
||||
const drifts: number[] = []
|
||||
let nextJankSample = performance.now() + 50
|
||||
const jankTimer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
drifts.push(Math.max(0, now - nextJankSample))
|
||||
nextJankSample = now + 50
|
||||
}, 50)
|
||||
let maxConcurrentChildren = 0
|
||||
const censusTimer = setInterval(() => {
|
||||
void liveChildCount(stateDir).then((count) => {
|
||||
maxConcurrentChildren = Math.max(maxConcurrentChildren, count)
|
||||
})
|
||||
}, 5)
|
||||
|
||||
const background = Array.from({ length: 60 }, (_, index) =>
|
||||
gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], {
|
||||
cwd: repoDirs[index % repoDirs.length],
|
||||
env: {
|
||||
...baseEnv,
|
||||
ORCA_STUB_ID: `background-${index}`,
|
||||
ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200',
|
||||
ORCA_STUB_STATE_DIR: stateDir
|
||||
},
|
||||
admissionTier: 'background'
|
||||
})
|
||||
)
|
||||
const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = []
|
||||
const interactiveLatencies = await Promise.all(
|
||||
Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) =>
|
||||
new Promise<number>((resolve, reject) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
void (async () => {
|
||||
const concurrentAtInjection = await liveChildCount(stateDir)
|
||||
const commandStartedAt = performance.now()
|
||||
const commandLabel = `interactive-${index}`
|
||||
interactiveQueueSnapshots.push({
|
||||
commandLabel,
|
||||
backgroundWaiterIds: _gitAdmissionSnapshotForTests()
|
||||
.queuedWaiters.filter((waiter) => waiter.tier === 'background')
|
||||
.map((waiter) => waiter.id)
|
||||
})
|
||||
await gitExecFileAsync(['rev-parse', commandLabel], {
|
||||
cwd: repoDirs[index % repoDirs.length],
|
||||
env: {
|
||||
...baseEnv,
|
||||
ORCA_STUB_ID: `interactive-${index}`,
|
||||
ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)),
|
||||
ORCA_STUB_STATE_DIR: stateDir
|
||||
},
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
resolve(performance.now() - commandStartedAt)
|
||||
})().catch(reject)
|
||||
},
|
||||
50 * (index + 1)
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
await Promise.all(background)
|
||||
clearInterval(censusTimer)
|
||||
clearInterval(jankTimer)
|
||||
maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir))
|
||||
const totalWallMs = performance.now() - startedAt
|
||||
return {
|
||||
mode,
|
||||
maxConcurrentChildren,
|
||||
eventLoopMaxDriftMs: Math.max(0, ...drifts),
|
||||
eventLoopP99DriftMs: percentile(drifts, 0.99),
|
||||
interactiveP50Ms: percentile(interactiveLatencies, 0.5),
|
||||
interactiveP95Ms: percentile(interactiveLatencies, 0.95),
|
||||
totalWallMs,
|
||||
admissionEvents,
|
||||
interactiveQueueSnapshots
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMeasurementTable(rows: readonly StormMeasurement[]): string {
|
||||
const rounded = rows.map((row) => ({
|
||||
mode: row.mode,
|
||||
maxConcurrentChildren: row.maxConcurrentChildren,
|
||||
eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1),
|
||||
eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1),
|
||||
interactiveP50Ms: row.interactiveP50Ms.toFixed(1),
|
||||
interactiveP95Ms: row.interactiveP95Ms.toFixed(1),
|
||||
totalWallMs: row.totalWallMs.toFixed(1)
|
||||
}))
|
||||
return JSON.stringify(rounded)
|
||||
}
|
||||
|
||||
export function assertAdmissionLedger(
|
||||
measurement: Pick<StormMeasurement, 'admissionEvents' | 'interactiveQueueSnapshots'>
|
||||
): void {
|
||||
const activeWaiters = new Set<number>()
|
||||
const grantSequenceByWaiter = new Map<number, number>()
|
||||
const grantByLabel = new Map<string, GitAdmissionEvent>()
|
||||
|
||||
measurement.admissionEvents.forEach((event, index) => {
|
||||
expect(event.sequence).toBe(index)
|
||||
if (event.phase === 'grant') {
|
||||
activeWaiters.add(event.waiterId)
|
||||
grantSequenceByWaiter.set(event.waiterId, event.sequence)
|
||||
const label = event.args.find((arg) => arg.startsWith('interactive-'))
|
||||
if (label) {
|
||||
grantByLabel.set(label, event)
|
||||
}
|
||||
} else {
|
||||
expect(activeWaiters.delete(event.waiterId)).toBe(true)
|
||||
}
|
||||
expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN)
|
||||
for (const budget of event.budgets) {
|
||||
expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity)
|
||||
expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity)
|
||||
}
|
||||
})
|
||||
expect(activeWaiters.size).toBe(0)
|
||||
|
||||
for (const snapshot of measurement.interactiveQueueSnapshots) {
|
||||
const interactiveGrant = grantByLabel.get(snapshot.commandLabel)
|
||||
expect(interactiveGrant).toBeDefined()
|
||||
const preceded = snapshot.backgroundWaiterIds.filter(
|
||||
(waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity)
|
||||
).length
|
||||
expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9))
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,11 @@ export {
|
||||
type PullRequestPushTarget
|
||||
} from './client/lookup/pull-request-push-target'
|
||||
export { getRepoSlug, getRepoUpstream } from './client/fetch/repo-slug-upstream'
|
||||
export { getWorkItem, getWorkItemByOwnerRepo } from './client/fetch/get-work-item'
|
||||
export {
|
||||
getWorkItem,
|
||||
getWorkItemByOwnerRepo,
|
||||
getWorkItemWithRepository
|
||||
} from './client/fetch/get-work-item'
|
||||
export { listWorkItems } from './client/list/list-work-items'
|
||||
export { mergePR } from './client/merge/merge-pr'
|
||||
export { removePRReviewers, requestPRReviewers } from './client/update/pr-reviewers'
|
||||
|
||||
@@ -21,6 +21,30 @@ export async function getWorkItem(
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<MainWorkItem | null> {
|
||||
const { item } = await getWorkItemWithRepository(
|
||||
repoPath,
|
||||
number,
|
||||
type,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
return item
|
||||
}
|
||||
|
||||
export type WorkItemRepositoryLookup = {
|
||||
item: MainWorkItem | null
|
||||
repository: GitHubApiRepository | null
|
||||
}
|
||||
|
||||
export async function getWorkItemWithRepository(
|
||||
repoPath: string,
|
||||
number: number,
|
||||
type?: 'issue' | 'pr',
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<WorkItemRepositoryLookup> {
|
||||
await acquire()
|
||||
try {
|
||||
// Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number
|
||||
@@ -35,18 +59,24 @@ export async function getWorkItem(
|
||||
// Why: explicit origin with no origin identity must not bare-lookup ambient gh
|
||||
// (same fail-closed rule as origin-pinned PR candidate resolution).
|
||||
if (!source && preference === 'origin') {
|
||||
return null
|
||||
return { item: null, repository: null }
|
||||
}
|
||||
return {
|
||||
item: await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions),
|
||||
repository: source
|
||||
}
|
||||
return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions)
|
||||
}
|
||||
if (type === 'pr') {
|
||||
return await fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
return {
|
||||
item: await fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
),
|
||||
repository: null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -65,7 +95,7 @@ export async function getWorkItem(
|
||||
localGitOptions
|
||||
)
|
||||
if (issue) {
|
||||
return issue
|
||||
return { item: issue, repository: source }
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -75,15 +105,18 @@ export async function getWorkItem(
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return await fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
return {
|
||||
item: await fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
),
|
||||
repository: null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
return { item: null, repository: null }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
|
||||
@@ -22,4 +22,25 @@ describe('isValidGitHubApiRepository', () => {
|
||||
expect(isValidGitHubApiRepository({ owner: 'acme', repo: '..' })).toBe(false)
|
||||
expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'a/b' })).toBe(false)
|
||||
})
|
||||
|
||||
it.each([42, false, null, {}])('rejects a non-string repository host: %j', (host) => {
|
||||
expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(false)
|
||||
})
|
||||
|
||||
it.each([undefined, 'github.com', 'github.enterprise.test:8443'])(
|
||||
'accepts supported legacy or explicit repository host types: %j',
|
||||
(host) => {
|
||||
expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
null,
|
||||
'acme/orca',
|
||||
{ owner: 42, repo: 'orca' },
|
||||
{ owner: 'acme', repo: 42 },
|
||||
{ owner: 'acme' }
|
||||
])('rejects malformed runtime repository identities: %j', (repository) => {
|
||||
expect(isValidGitHubApiRepository(repository)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,8 +11,21 @@ export type GitHubApiRepositoryResolution =
|
||||
const OWNER_SLUG_RE = GITHUB_OWNER_SLUG_RE
|
||||
const REPOSITORY_SLUG_RE = /^[A-Za-z0-9._-]+$/
|
||||
|
||||
export function isValidGitHubApiRepository(repository: GitHubOwnerRepo): boolean {
|
||||
export function isValidGitHubApiRepository(repository: unknown): repository is GitHubOwnerRepo {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository !== 'object' ||
|
||||
!('owner' in repository) ||
|
||||
!('repo' in repository)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
typeof repository.owner === 'string' &&
|
||||
typeof repository.repo === 'string' &&
|
||||
(!('host' in repository) ||
|
||||
repository.host === undefined ||
|
||||
typeof repository.host === 'string') &&
|
||||
OWNER_SLUG_RE.test(repository.owner) &&
|
||||
REPOSITORY_SLUG_RE.test(repository.repo) &&
|
||||
repository.repo !== '.' &&
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import type { GitHubCommentResult, PRComment } from '../../shared/github/comment-types'
|
||||
import type { IssueSourcePreference } from '../../shared/repo-types'
|
||||
import type { LocalGitExecOptions, OwnerRepo } from './gh-utils'
|
||||
import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
|
||||
import {
|
||||
resolveGitHubRepoExecution,
|
||||
resolveIssueGitHubApiRepositorySource
|
||||
} from './github-api-repository'
|
||||
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
|
||||
|
||||
/**
|
||||
* Add a comment to an existing GitHub issue.
|
||||
*
|
||||
* Why this path doesn't take a preference (mirrors `getIssue` / `updateIssue`):
|
||||
* a comment is posted against an issue number already bound to a worktree or
|
||||
* surfaced from a prior read. Routing through the live per-repo preference
|
||||
* would let a user read upstream#N, toggle the selector to origin, and have
|
||||
* their reply silently post on origin#N — a different issue entirely. That
|
||||
* is the same silent-source-switch class of wrongness #1186 / the parent
|
||||
* design doc guard against. List and create paths honor preference;
|
||||
* mutations stay on the heuristic `getIssueOwnerRepo`.
|
||||
*/
|
||||
// An explicit target keeps replies bound to the conversation; otherwise use the selected source.
|
||||
export async function addIssueComment(
|
||||
repoPath: string,
|
||||
issueNumber: number,
|
||||
body: string,
|
||||
connectionId?: string | null,
|
||||
ownerRepoOverride?: OwnerRepo | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<GitHubCommentResult> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
ownerRepoOverride ??
|
||||
(() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)),
|
||||
(async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GitHubAssignableUser } from '../../shared/github/pull-request-types'
|
||||
import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../shared/github/pull-request-types'
|
||||
import type { IssueSourcePreference } from '../../shared/repo-types'
|
||||
import type { LocalGitExecOptions } from './gh-utils'
|
||||
import {
|
||||
@@ -11,19 +11,21 @@ export async function listLabels(
|
||||
repoPath: string,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
repositoryOverride?: GitHubOwnerRepo | null
|
||||
): Promise<string[]> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source,
|
||||
repositoryOverride ??
|
||||
(async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
@@ -57,19 +59,21 @@ export async function listAssignableUsers(
|
||||
repoPath: string,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
repositoryOverride?: GitHubOwnerRepo | null
|
||||
): Promise<GitHubAssignableUser[]> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source,
|
||||
repositoryOverride ??
|
||||
(async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
import type * as GhUtils from './gh-utils'
|
||||
|
||||
const { ghExecFileAsyncMock, resolveIssueSourceMock, acquireMock, releaseMock } = vi.hoisted(
|
||||
() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
resolveIssueSourceMock: vi.fn(),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('./gh-utils', async () => {
|
||||
const actual = await vi.importActual<typeof GhUtils>('./gh-utils')
|
||||
return {
|
||||
...actual,
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
acquire: acquireMock,
|
||||
release: releaseMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./github-api-repository', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GithubApiRepositoryModule>()),
|
||||
resolveIssueGitHubApiRepositorySource: resolveIssueSourceMock
|
||||
}))
|
||||
|
||||
import { addIssueComment } from './issue-comment'
|
||||
import { updateIssue } from './issue-update'
|
||||
|
||||
const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
|
||||
const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' }
|
||||
|
||||
describe('issue mutations follow the issue source preference', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
acquireMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
releaseMock.mockReset()
|
||||
resolveIssueSourceMock.mockReset()
|
||||
resolveIssueSourceMock.mockImplementation(async (_repoPath: string, preference: unknown) => ({
|
||||
source: preference === 'origin' ? ORIGIN : UPSTREAM,
|
||||
fellBack: false
|
||||
}))
|
||||
})
|
||||
|
||||
it('posts issue comments to origin when origin is selected', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 1, user: { login: 'octo', avatar_url: '' }, body: 'hi' })
|
||||
})
|
||||
|
||||
await expect(
|
||||
addIssueComment('/repo-root', 5, 'hi', null, null, {}, 'origin')
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
|
||||
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
|
||||
'repos/fork-owner/widgets/issues/5/comments'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an explicit owner/repo override ahead of the preference', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 2, user: { login: 'octo', avatar_url: '' }, body: 'hi' })
|
||||
})
|
||||
|
||||
await addIssueComment('/repo-root', 7, 'hi', null, UPSTREAM, {}, 'origin')
|
||||
|
||||
expect(resolveIssueSourceMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
|
||||
'repos/upstream-owner/widgets/issues/7/comments'
|
||||
)
|
||||
})
|
||||
|
||||
it('edits issues on origin when origin is selected', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
|
||||
|
||||
await expect(
|
||||
updateIssue('/repo-root', 5, { body: 'Updated', addAssignees: ['octo'] }, null, {}, 'origin')
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/fork-owner/widgets/issues/5')
|
||||
expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([
|
||||
'issue',
|
||||
'edit',
|
||||
'5',
|
||||
'--repo',
|
||||
'fork-owner/widgets',
|
||||
'--add-assignee',
|
||||
'octo'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the upstream-first default when no preference is set', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
|
||||
|
||||
await updateIssue('/repo-root', 5, { body: 'Updated' })
|
||||
|
||||
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', undefined, undefined, {})
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/upstream-owner/widgets/issues/5')
|
||||
})
|
||||
|
||||
it('keeps edits on the opened issue when the saved preference changes', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
|
||||
|
||||
await updateIssue(
|
||||
'/repo-root',
|
||||
5,
|
||||
{ body: 'Origin edit', state: 'closed', addLabels: ['bug'], addAssignees: ['octo'] },
|
||||
null,
|
||||
{},
|
||||
'upstream',
|
||||
ORIGIN
|
||||
)
|
||||
|
||||
expect(resolveIssueSourceMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([
|
||||
['issue', 'close', '5', '--repo', 'fork-owner/widgets'],
|
||||
[
|
||||
'api',
|
||||
'-X',
|
||||
'PATCH',
|
||||
'repos/fork-owner/widgets/issues/5',
|
||||
'--raw-field',
|
||||
'body=Origin edit'
|
||||
],
|
||||
[
|
||||
'issue',
|
||||
'edit',
|
||||
'5',
|
||||
'--repo',
|
||||
'fork-owner/widgets',
|
||||
'--add-label',
|
||||
'bug',
|
||||
'--add-assignee',
|
||||
'octo'
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves local WSL execution with an explicit issue repository', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
|
||||
|
||||
await updateIssue(
|
||||
'/home/fixture/widgets',
|
||||
5,
|
||||
{ body: 'Origin edit' },
|
||||
null,
|
||||
{ wslDistro: 'Ubuntu' },
|
||||
'upstream',
|
||||
ORIGIN
|
||||
)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['repos/fork-owner/widgets/issues/5']),
|
||||
expect.objectContaining({
|
||||
cwd: '/home/fixture/widgets',
|
||||
wslDistro: 'Ubuntu',
|
||||
host: 'github.com'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses unresolved Origin mutations instead of using ambient gh', async () => {
|
||||
resolveIssueSourceMock.mockResolvedValue({ source: null, fellBack: false })
|
||||
|
||||
await expect(
|
||||
updateIssue('/repo-root', 5, { body: 'Origin edit' }, null, {}, 'origin')
|
||||
).resolves.toMatchObject({ ok: false })
|
||||
await expect(
|
||||
addIssueComment('/repo-root', 5, 'Origin reply', null, null, {}, 'origin')
|
||||
).resolves.toMatchObject({ ok: false })
|
||||
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,31 +1,35 @@
|
||||
import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types'
|
||||
import type { IssueSourcePreference } from '../../shared/repo-types'
|
||||
import type { LocalGitExecOptions } from './gh-utils'
|
||||
import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
|
||||
import {
|
||||
resolveGitHubRepoExecution,
|
||||
resolveIssueGitHubApiRepositorySource,
|
||||
type GitHubApiRepository
|
||||
} from './github-api-repository'
|
||||
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
|
||||
|
||||
/**
|
||||
* Update an existing GitHub issue. Fans out to separate gh commands for
|
||||
* state changes vs field edits since `gh issue edit` does not support state.
|
||||
*
|
||||
* Why this path doesn't take a preference (mirrors `getIssue`): mutations
|
||||
* target an issue number already bound to a worktree / linked elsewhere in
|
||||
* the UI. Routing an update through the live per-repo preference would let
|
||||
* a user open upstream#N, toggle the selector to origin, save, and silently
|
||||
* write to origin#N — a different issue (or 404). That is the exact
|
||||
* silent-source-switch class of wrongness #1186 / the parent design doc
|
||||
* guard against. List and create paths honor preference; mutations stay on
|
||||
* the heuristic `getIssueOwnerRepo`.
|
||||
*/
|
||||
// Use the selected source for issue-number edits.
|
||||
export async function updateIssue(
|
||||
repoPath: string,
|
||||
issueNumber: number,
|
||||
updates: GitHubIssueUpdate,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference,
|
||||
ownerRepoOverride?: GitHubApiRepository | null
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
ownerRepoOverride ??
|
||||
(async () =>
|
||||
(
|
||||
await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
).source),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
@@ -27,8 +28,11 @@ vi.mock('./gh-utils', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getPRComments: getPRCommentsMock,
|
||||
getPRChecks: getPRChecksMock
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as NodeOs from 'node:os'
|
||||
import type * as GitExecError from '../git/exec-error'
|
||||
import type * as WorkItemLookup from './client/fetch/get-work-item'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
|
||||
const { ghMock, fixture } = vi.hoisted(() => {
|
||||
const fixture: {
|
||||
path: string
|
||||
initialHost: string
|
||||
replacementUrl: string
|
||||
requests: { args: string[]; host?: string }[]
|
||||
} = { path: '', initialHost: '', replacementUrl: '', requests: [] }
|
||||
return { ghMock: vi.fn(), fixture }
|
||||
})
|
||||
|
||||
vi.mock('node:os', async (original) => ({
|
||||
...(await original<typeof NodeOs>()),
|
||||
homedir: () => join(fixture.path, 'isolated-home')
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', async () => {
|
||||
const errors = await vi.importActual<typeof GitExecError>('../git/exec-error')
|
||||
return {
|
||||
...errors,
|
||||
ghExecFileAsync: ghMock,
|
||||
gitExecFileAsync: async (args: string[], options: { cwd?: string }) => {
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig')
|
||||
}
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr)
|
||||
}
|
||||
return { stdout: result.stdout, stderr: result.stderr }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./client', async () => ({
|
||||
...(await vi.importActual<typeof WorkItemLookup>('./client/fetch/get-work-item')),
|
||||
getPRChecks: vi.fn(),
|
||||
getPRComments: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./rate-limit', () => ({
|
||||
repositoryRateLimitGuard: () => ({ blocked: false }),
|
||||
noteRepositoryRateLimitSpend: vi.fn()
|
||||
}))
|
||||
|
||||
import { getWorkItemDetails } from './work-item-details'
|
||||
import { _resetOwnerRepoCache } from './github-repository-identity'
|
||||
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
|
||||
import { _resetGitHubHostAuthCache } from './github-enterprise-repository'
|
||||
|
||||
async function fixtureGit(args: string[]): Promise<void> {
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
args,
|
||||
cwd: fixture.path,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig')
|
||||
}
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
fixture.path = await mkdtemp(join(tmpdir(), 'orca-issue-config-change-'))
|
||||
await mkdir(join(fixture.path, 'isolated-home'))
|
||||
await fixtureGit(['init', '--quiet'])
|
||||
await fixtureGit(['remote', 'add', 'origin', 'https://github.com/fork-owner/widgets.git'])
|
||||
_resetOwnerRepoCache()
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
_resetGitHubHostAuthCache()
|
||||
fixture.initialHost = 'github.com'
|
||||
fixture.replacementUrl = 'https://github.com/replacement-owner/widgets.git'
|
||||
fixture.requests = []
|
||||
ghMock.mockReset()
|
||||
ghMock.mockImplementation(async (args: string[], options: { host?: string }) => {
|
||||
fixture.requests.push({ args, host: options.host })
|
||||
if (args[0] === 'auth' && args[1] === 'status') {
|
||||
return {
|
||||
stdout: ['github.com', 'github.enterprise.test']
|
||||
.map((host) => `${host}\n Logged in to ${host} account fixture (keyring)`)
|
||||
.join('\n'),
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args.includes('repos/fork-owner/widgets/issues/5')) {
|
||||
await fixtureGit(['remote', 'set-url', 'origin', fixture.replacementUrl])
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
number: 5,
|
||||
title: 'ORIGIN title',
|
||||
state: 'open',
|
||||
html_url: `https://${fixture.initialHost}/fork-owner/widgets/issues/5`,
|
||||
labels: [],
|
||||
updated_at: '2026-10-02T00:00:00Z',
|
||||
user: { login: 'fork-author' }
|
||||
}),
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args.includes('graphql')) {
|
||||
const original = args.includes('owner=fork-owner') && options.host === fixture.initialHost
|
||||
const marker = original ? 'ORIGIN' : 'REPLACEMENT'
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body: `${marker} body`,
|
||||
assignees: { nodes: [{ login: `${marker}-assignee` }] },
|
||||
participants: { nodes: [] },
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
databaseId: 1,
|
||||
body: `${marker} comment`,
|
||||
createdAt: '2026-10-02T00:00:00Z',
|
||||
url: `https://${options.host}/fork-owner/widgets/issues/5#issuecomment-1`,
|
||||
author: { login: `${marker}-author`, avatarUrl: '' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args.some((arg) => arg.includes('/timeline?'))) {
|
||||
return { stdout: '[]', stderr: '' }
|
||||
}
|
||||
throw new Error(`Unexpected fixture gh request: ${args.join(' ')}`)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(fixture.path, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each(['issue', undefined] as const)(
|
||||
'keeps implicit %s conversation on the repository used for the item fetch',
|
||||
async (type) => {
|
||||
const details = await getWorkItemDetails(fixture.path, 5, type, null, {}, 'origin')
|
||||
expect(details?.item.title).toBe('ORIGIN title')
|
||||
expect(details?.body).toBe('ORIGIN body')
|
||||
expect(details?.assignees).toEqual(['ORIGIN-assignee'])
|
||||
expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment'])
|
||||
expect(fixture.requests[1].args).toContain('owner=fork-owner')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an explicit local Tasks identity through the same real config change', async () => {
|
||||
const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', {
|
||||
owner: 'fork-owner',
|
||||
repo: 'widgets',
|
||||
host: 'github.com'
|
||||
})
|
||||
expect(details?.item.title).toBe('ORIGIN title')
|
||||
expect(details?.body).toBe('ORIGIN body')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ from: 'github.com', to: 'github.enterprise.test', qualified: false },
|
||||
{ from: 'github.enterprise.test', to: 'github.com', qualified: false },
|
||||
{ from: 'github.com', to: 'github.enterprise.test', qualified: true },
|
||||
{ from: 'github.enterprise.test', to: 'github.com', qualified: true }
|
||||
])(
|
||||
'keeps explicit host from $from to $to (qualified=$qualified)',
|
||||
async ({ from, to, qualified }) => {
|
||||
fixture.initialHost = from
|
||||
fixture.replacementUrl = `https://${to}/fork-owner/widgets.git`
|
||||
await fixtureGit(['remote', 'set-url', 'origin', `https://${from}/fork-owner/widgets.git`])
|
||||
const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', {
|
||||
owner: 'fork-owner',
|
||||
repo: 'widgets',
|
||||
...(qualified ? { host: from } : {})
|
||||
})
|
||||
expect(details?.item.url).toBe(`https://${from}/fork-owner/widgets/issues/5`)
|
||||
expect(details?.body).toBe('ORIGIN body')
|
||||
expect(details?.assignees).toEqual(['ORIGIN-assignee'])
|
||||
expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment'])
|
||||
const conversationRequests = fixture.requests.filter(
|
||||
({ args }) => args.includes('graphql') || args.some((arg) => arg.includes('/timeline?'))
|
||||
)
|
||||
expect(conversationRequests.some(({ args }) => args.includes('graphql'))).toBe(true)
|
||||
expect(
|
||||
conversationRequests.some(({ args }) => args.some((arg) => arg.includes('/timeline?')))
|
||||
).toBe(true)
|
||||
expect(conversationRequests.map(({ host }) => host)).toEqual([from, from])
|
||||
}
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RateLimitGuardResult =
|
||||
@@ -47,8 +48,11 @@ vi.mock('./gh-utils', () => ({
|
||||
release: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
|
||||
getPRChecks: getPRChecksMock,
|
||||
getPRComments: getPRCommentsMock
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RateLimitGuardResult =
|
||||
@@ -59,8 +60,11 @@ vi.mock('./gh-utils', () => ({
|
||||
release: releaseMock
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: vi.fn(),
|
||||
getPRChecks: getPRChecksMock,
|
||||
getPRComments: getPRCommentsMock
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
getOwnerRepoForRemoteMock,
|
||||
getWorkItemMock,
|
||||
getWorkItemByOwnerRepoMock,
|
||||
acquireMock,
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
getOwnerRepoForRemoteMock: vi.fn(),
|
||||
getWorkItemMock: vi.fn(),
|
||||
getWorkItemByOwnerRepoMock: vi.fn(),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./gh-utils', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
getOwnerRepo: getOwnerRepoMock,
|
||||
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
|
||||
ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })),
|
||||
githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null,
|
||||
...localGitOptions
|
||||
})),
|
||||
acquire: acquireMock,
|
||||
release: releaseMock
|
||||
}))
|
||||
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
|
||||
getPRChecks: vi.fn(),
|
||||
getPRComments: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./github-enterprise-repository', () => ({
|
||||
getEnterpriseGitHubRepoSlug: vi.fn().mockResolvedValue(null),
|
||||
getEnterpriseGitHubRepoSlugForRemote: vi.fn().mockResolvedValue(null),
|
||||
isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
vi.mock('./rate-limit', () => ({
|
||||
repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })),
|
||||
noteRepositoryRateLimitSpend: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/remote-name-listing', () => ({
|
||||
shouldProbeGitRemote: vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
import { getWorkItemDetails } from './work-item-details'
|
||||
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
|
||||
|
||||
const REMOTES: Record<string, { owner: string; repo: string }> = {
|
||||
origin: { owner: 'fork-owner', repo: 'widgets' },
|
||||
upstream: { owner: 'upstream-owner', repo: 'widgets' }
|
||||
}
|
||||
|
||||
function graphQLIssueResponse(assignee: string): { stdout: string } {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body: `${assignee} body`,
|
||||
assignees: { nodes: [{ login: assignee }] },
|
||||
participants: { nodes: [] },
|
||||
comments: { nodes: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function graphQLOwnerArg(): string | undefined {
|
||||
const args: string[] = ghExecFileAsyncMock.mock.calls[0][0]
|
||||
return args.find((arg) => arg.startsWith('owner='))
|
||||
}
|
||||
|
||||
describe('getWorkItemDetails issue source', () => {
|
||||
beforeEach(() => {
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
getOwnerRepoMock.mockReset()
|
||||
getOwnerRepoMock.mockResolvedValue(REMOTES.origin)
|
||||
getOwnerRepoForRemoteMock.mockReset()
|
||||
getOwnerRepoForRemoteMock.mockImplementation(
|
||||
async (_repoPath: string, remoteName: string) => REMOTES[remoteName] ?? null
|
||||
)
|
||||
getWorkItemMock.mockReset()
|
||||
getWorkItemByOwnerRepoMock.mockReset()
|
||||
getWorkItemMock.mockResolvedValue({
|
||||
id: 'issue:5',
|
||||
type: 'issue',
|
||||
number: 5,
|
||||
title: 'Origin issue',
|
||||
state: 'open',
|
||||
url: 'https://github.com/fork-owner/widgets/issues/5',
|
||||
labels: [],
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
author: 'fork-author'
|
||||
})
|
||||
acquireMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
releaseMock.mockReset()
|
||||
})
|
||||
|
||||
it('reads body, assignees and comments from origin when origin is selected', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin')
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 5, 'issue', null, {}, 'origin')
|
||||
expect(graphQLOwnerArg()).toBe('owner=fork-owner')
|
||||
expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain(
|
||||
'repos/fork-owner/widgets/issues/5/timeline?per_page=100&page=1'
|
||||
)
|
||||
expect(details?.assignees).toEqual(['fork-assignee'])
|
||||
})
|
||||
|
||||
it('keeps the upstream-first default when no preference is set', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee'))
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {})
|
||||
|
||||
expect(graphQLOwnerArg()).toBe('owner=upstream-owner')
|
||||
expect(details?.assignees).toEqual(['upstream-assignee'])
|
||||
})
|
||||
|
||||
it('keeps a Project row on upstream even when Origin is selected', async () => {
|
||||
const upstream = { ...REMOTES.upstream, host: 'github.com' }
|
||||
getWorkItemByOwnerRepoMock.mockResolvedValue({
|
||||
id: 'issue:5',
|
||||
type: 'issue',
|
||||
number: 5,
|
||||
title: 'Upstream project issue',
|
||||
url: 'https://github.com/upstream-owner/widgets/issues/5',
|
||||
state: 'open',
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: 'upstream-author'
|
||||
})
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee'))
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin', upstream)
|
||||
|
||||
expect(getWorkItemMock).not.toHaveBeenCalled()
|
||||
expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith(
|
||||
'/repo-root',
|
||||
upstream,
|
||||
5,
|
||||
'issue',
|
||||
null,
|
||||
{}
|
||||
)
|
||||
expect(graphQLOwnerArg()).toBe('owner=upstream-owner')
|
||||
expect(details?.item.title).toBe('Upstream project issue')
|
||||
expect(details?.assignees).toEqual(['upstream-assignee'])
|
||||
})
|
||||
|
||||
it('does not fall back when the selected issue repository is unavailable', async () => {
|
||||
getWorkItemByOwnerRepoMock.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', {
|
||||
...REMOTES.origin,
|
||||
host: 'github.com'
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(getWorkItemMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an invalid runtime issue host before calling the repository lookup', async () => {
|
||||
await expect(
|
||||
getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', {
|
||||
...REMOTES.origin,
|
||||
host: 42
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(getWorkItemMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores an issue repository override on an untyped lookup', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await getWorkItemDetails('/repo-root', 5, undefined, null, {}, 'origin', {
|
||||
owner: 'untrusted',
|
||||
repo: 'other',
|
||||
host: 'untrusted.example'
|
||||
})
|
||||
|
||||
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(graphQLOwnerArg()).toBe('owner=fork-owner')
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' })
|
||||
})
|
||||
|
||||
it('normalizes host-less issue targets before loading the conversation', async () => {
|
||||
getWorkItemByOwnerRepoMock.mockResolvedValue({
|
||||
type: 'issue',
|
||||
number: 5,
|
||||
title: 'Origin issue',
|
||||
url: 'https://github.com/fork-owner/widgets/issues/5',
|
||||
author: null
|
||||
})
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', REMOTES.origin)
|
||||
|
||||
expect(graphQLOwnerArg()).toBe('owner=fork-owner')
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' })
|
||||
expect(ghExecFileAsyncMock.mock.calls[1][1]).toMatchObject({ host: 'github.com' })
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RateLimitGuardResult =
|
||||
@@ -52,8 +53,11 @@ vi.mock('./gh-utils', () => ({
|
||||
release: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: vi.fn(),
|
||||
getPRChecks: getPRChecksMock,
|
||||
getPRComments: getPRCommentsMock
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { resolveIssueGitHubApiRepositorySource } from './github-api-repository'
|
||||
import type { getWorkItem } from './client'
|
||||
import type { WorkItemRepositoryLookup } from './client/fetch/get-work-item'
|
||||
|
||||
// Why: detail tests replace item lookup; retain its selected-source metadata.
|
||||
export function makeWorkItemDetailsLookupMock(lookup: typeof getWorkItem) {
|
||||
return async (...args: Parameters<typeof getWorkItem>): Promise<WorkItemRepositoryLookup> => {
|
||||
const item = await lookup(...args)
|
||||
return {
|
||||
item,
|
||||
repository:
|
||||
item?.type === 'issue'
|
||||
? (await resolveIssueGitHubApiRepositorySource(args[0], args[5], args[3], args[4])).source
|
||||
: null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RateLimitGuardResult =
|
||||
@@ -62,8 +63,11 @@ vi.mock('./gh-utils', () => ({
|
||||
release: releaseMock
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
vi.mock('./client', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
|
||||
getPRChecks: getPRChecksMock,
|
||||
getPRComments: getPRCommentsMock
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { PRCheckDetail } from '../../shared/github/check-types'
|
||||
import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types'
|
||||
import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types'
|
||||
import type { IssueSourcePreference } from '../../shared/repo-types'
|
||||
import { getPRChecks, getPRComments, getWorkItem } from './client'
|
||||
import { acquire, release, type LocalGitExecOptions } from './gh-utils'
|
||||
import {
|
||||
getIssueGitHubApiRepository,
|
||||
resolveGitHubRepoExecution,
|
||||
type GitHubApiRepository
|
||||
} from './github-api-repository'
|
||||
getPRChecks,
|
||||
getPRComments,
|
||||
getWorkItemWithRepository,
|
||||
getWorkItemByOwnerRepo
|
||||
} from './client'
|
||||
import { acquire, release, type LocalGitExecOptions } from './gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from './github-api-repository'
|
||||
import { isValidGitHubApiRepository } from './github-api-repository-validation'
|
||||
import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details'
|
||||
import {
|
||||
getPRFiles,
|
||||
@@ -69,23 +71,49 @@ export async function getWorkItemDetails(
|
||||
type?: 'issue' | 'pr',
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
preference?: IssueSourcePreference,
|
||||
repositoryOverride?: unknown
|
||||
): Promise<GitHubWorkItemDetails | null> {
|
||||
const item: Omit<GitHubWorkItem, 'repoId'> | null = await getWorkItem(
|
||||
repoPath,
|
||||
number,
|
||||
type,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
let issueRepositoryOverride: GitHubApiRepository | null = null
|
||||
if (type === 'issue' && repositoryOverride != null) {
|
||||
if (!isValidGitHubApiRepository(repositoryOverride)) {
|
||||
return null
|
||||
}
|
||||
issueRepositoryOverride = (
|
||||
await resolveGitHubRepoExecution(repoPath, repositoryOverride, connectionId, localGitOptions)
|
||||
).ownerRepo
|
||||
if (!issueRepositoryOverride) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const { item, repository: selectedRepository } = issueRepositoryOverride
|
||||
? {
|
||||
item: await getWorkItemByOwnerRepo(
|
||||
repoPath,
|
||||
issueRepositoryOverride,
|
||||
number,
|
||||
'issue',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
),
|
||||
repository: issueRepositoryOverride
|
||||
}
|
||||
: await getWorkItemWithRepository(
|
||||
repoPath,
|
||||
number,
|
||||
type,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Keep the conversation in the same repository as the selected issue.
|
||||
const resolvedRepository =
|
||||
item.type === 'issue'
|
||||
? await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)
|
||||
? selectedRepository
|
||||
: (await resolveGitHubRepoExecution(repoPath, item.prRepo, connectionId, localGitOptions))
|
||||
.ownerRepo
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
|
||||
['main/bitbucket/client.ts', 1],
|
||||
['main/bitbucket/user-request.ts', 1],
|
||||
['main/gitea/client.ts', 1],
|
||||
// Generated OpenCode claim source consumes JSON or cancels its body in finally.
|
||||
['main/opencode/opencode-startup-prompt-source.ts', 1],
|
||||
['main/orca-profiles/profile-cloud-client.ts', 1],
|
||||
['main/orca-profiles/profile-cloud-org-members-client.ts', 1],
|
||||
['main/rate-limits/codex-fetcher.ts', 3],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { access, chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { isCommandOnLocalPath } from './command-path-resolver'
|
||||
import { isCommandOnLocalPath, listLocalCommandPaths } from './command-path-resolver'
|
||||
|
||||
describe('isCommandOnLocalPath', () => {
|
||||
it('returns false for an empty command', async () => {
|
||||
@@ -112,4 +112,129 @@ describe('isCommandOnLocalPath', () => {
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Why the full list exists (#22975): a dead version-manager shim passes the
|
||||
// same executable check as the binary it shadows, so a winner-only lookup can
|
||||
// only ever hand the caller the shim. The copies behind it are the answer.
|
||||
describe.skipIf(process.platform === 'win32')('listLocalCommandPaths', () => {
|
||||
let front = ''
|
||||
let middle = ''
|
||||
let back = ''
|
||||
|
||||
async function executable(dir: string, name: string, body: string): Promise<void> {
|
||||
await writeFile(path.join(dir, name), body)
|
||||
await chmod(path.join(dir, name), 0o755)
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
front = await mkdtemp(path.join(tmpdir(), 'cmd-list-front-'))
|
||||
middle = await mkdtemp(path.join(tmpdir(), 'cmd-list-middle-'))
|
||||
back = await mkdtemp(path.join(tmpdir(), 'cmd-list-back-'))
|
||||
await executable(front, 'gh', '#!/usr/bin/env bash\nexec /nope/asdf exec "gh" "$@"\n')
|
||||
await executable(back, 'gh', "#!/bin/sh\nprintf 'gh version 2.98.0\\n'\n")
|
||||
// Both a non-executable file and a directory in the middle: neither is a
|
||||
// match, and neither may end the scan.
|
||||
await writeFile(path.join(middle, 'gh'), 'not executable\n')
|
||||
await chmod(path.join(middle, 'gh'), 0o644)
|
||||
await mkdir(path.join(middle, 'glab'))
|
||||
await executable(back, 'glab', '#!/bin/sh\n')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(front, { recursive: true, force: true })
|
||||
await rm(middle, { recursive: true, force: true })
|
||||
await rm(back, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns every match in PATH order, shim first', async () => {
|
||||
await expect(
|
||||
listLocalCommandPaths('gh', {
|
||||
platform: 'linux',
|
||||
env: { PATH: `${front}:${middle}:${back}` }
|
||||
})
|
||||
).resolves.toEqual([path.posix.join(front, 'gh'), path.posix.join(back, 'gh')])
|
||||
})
|
||||
|
||||
it('follows PATH order rather than which copy runs', async () => {
|
||||
await expect(
|
||||
listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: `${back}:${front}` } })
|
||||
).resolves.toEqual([path.posix.join(back, 'gh'), path.posix.join(front, 'gh')])
|
||||
})
|
||||
|
||||
it('skips a directory that matches the command name and keeps scanning', async () => {
|
||||
await expect(
|
||||
listLocalCommandPaths('glab', {
|
||||
platform: 'linux',
|
||||
env: { PATH: `${middle}:${front}:${back}` }
|
||||
})
|
||||
).resolves.toEqual([path.posix.join(back, 'glab')])
|
||||
})
|
||||
|
||||
it('returns an empty list when PATH holds no absolute match', async () => {
|
||||
await expect(
|
||||
listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: '' } })
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('applies the absolute-only gate to the whole list, not just the winner', async () => {
|
||||
await expect(
|
||||
listLocalCommandPaths('gh', {
|
||||
platform: 'linux',
|
||||
env: { PATH: `.${path.delimiter}${front}` }
|
||||
})
|
||||
).resolves.toEqual([path.posix.join(front, 'gh')])
|
||||
})
|
||||
|
||||
it('drops a relative match the filesystem would otherwise find', async () => {
|
||||
// Why a second relative case: `path.posix.join('.', 'gh')` collapses to
|
||||
// `gh`, so the case above is unaffected by deleting the gate. `fs.access`
|
||||
// resolves a relative candidate against the process cwd, so only a PATH
|
||||
// entry that really reaches a fixture proves the filter — and the `access`
|
||||
// proves that reach first, making this fail loudly instead of going
|
||||
// vacuous if the suite is ever run from a directory that hides it.
|
||||
const relative = path.relative(process.cwd(), back)
|
||||
await expect(access(path.join(relative, 'gh'))).resolves.toBeUndefined()
|
||||
await expect(
|
||||
listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: relative } })
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('lists a PATH entry that repeats only once', async () => {
|
||||
// Why it matters: callers spawn each entry, and `/usr/local/bin` appearing
|
||||
// twice in a real PATH is normal — a doomed shim must not be probed twice.
|
||||
await expect(
|
||||
listLocalCommandPaths('gh', {
|
||||
platform: 'linux',
|
||||
env: { PATH: `${back}:${front}:${back}` }
|
||||
})
|
||||
).resolves.toEqual([path.posix.join(back, 'gh'), path.posix.join(front, 'gh')])
|
||||
})
|
||||
|
||||
it('returns an empty list for an empty command', async () => {
|
||||
await expect(listLocalCommandPaths('')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('resolves an absolute command path directly', async () => {
|
||||
await expect(
|
||||
listLocalCommandPaths(path.join(front, 'gh'), { platform: 'linux', env: { PATH: '' } })
|
||||
).resolves.toEqual([`${front}/gh`])
|
||||
})
|
||||
|
||||
it('lists every PATHEXT permutation on win32, in resolver order', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'cmd-list-win32-'))
|
||||
try {
|
||||
await writeFile(path.join(dir, 'tool.CMD'), '@echo off\n')
|
||||
await writeFile(path.join(dir, 'tool.EXE'), '')
|
||||
await expect(
|
||||
listLocalCommandPaths('tool', {
|
||||
platform: 'win32',
|
||||
env: { Path: dir, PATHEXT: '.CMD;.EXE' },
|
||||
cwd: dir
|
||||
})
|
||||
).resolves.toEqual([`${dir}/tool.CMD`, `${dir}/tool.EXE`])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { access, constants as fsConstants, stat } from 'node:fs/promises'
|
||||
import { statSync, type Stats } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
export type ResolveCommandOptions = {
|
||||
@@ -8,6 +10,8 @@ export type ResolveCommandOptions = {
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** CWD used only for the win32 "search current directory first" rule. */
|
||||
cwd?: string
|
||||
/** Stop after this many matches; defaults to the complete list. */
|
||||
maxResults?: number
|
||||
}
|
||||
|
||||
// Why: Windows env keys are case-insensitive (PATH is usually stored as `Path`,
|
||||
@@ -38,6 +42,109 @@ function getWindowsExtensions(env: NodeJS.ProcessEnv, command: string): string[]
|
||||
return extensions
|
||||
}
|
||||
|
||||
type LocalCommandSelection = {
|
||||
scope: string
|
||||
selected?: { binary: string; stamp: string; cwd?: string }
|
||||
}
|
||||
|
||||
const localCommandSelections = new Map<string, LocalCommandSelection>()
|
||||
|
||||
function selectionScope(options: ResolveCommandOptions): string {
|
||||
const platform = options.platform ?? process.platform
|
||||
const env = options.env ?? process.env
|
||||
const isWin = platform === 'win32'
|
||||
const pathValue = readEnvCaseInsensitive(env, 'PATH') ?? ''
|
||||
const pathApi = isWin ? path.win32 : path.posix
|
||||
const needsCwd = pathValue.split(isWin ? ';' : ':').some((dir) => !pathApi.isAbsolute(dir))
|
||||
return JSON.stringify([
|
||||
platform,
|
||||
pathValue,
|
||||
isWin ? readEnvCaseInsensitive(env, 'PATHEXT') : null,
|
||||
env.HOME,
|
||||
env.USERPROFILE,
|
||||
homedir(),
|
||||
needsCwd ? (options.cwd ?? process.cwd()) : null
|
||||
])
|
||||
}
|
||||
|
||||
function commandFileStamp(stats: Stats): string {
|
||||
return [stats.dev, stats.ino, stats.size, stats.mtimeMs, stats.ctimeMs, stats.mode].join(':')
|
||||
}
|
||||
|
||||
/** Publish only a successful version probe; a newer probe supersedes an older one. */
|
||||
export function beginLocalCommandSelection(
|
||||
command: string
|
||||
): (binary: string | null) => Promise<void> {
|
||||
if (command !== 'gh' && command !== 'glab') {
|
||||
return async () => {}
|
||||
}
|
||||
const scope = selectionScope({})
|
||||
const probeCwd = process.cwd()
|
||||
const previous = localCommandSelections.get(command)
|
||||
const selection: LocalCommandSelection = {
|
||||
scope,
|
||||
selected: previous?.scope === scope ? previous.selected : undefined
|
||||
}
|
||||
localCommandSelections.set(command, selection)
|
||||
return async (binary) => {
|
||||
if (localCommandSelections.get(command) !== selection) {
|
||||
return
|
||||
}
|
||||
if (binary === null || !path.isAbsolute(binary)) {
|
||||
delete selection.selected
|
||||
return
|
||||
}
|
||||
try {
|
||||
const stats = await stat(binary)
|
||||
if (localCommandSelections.get(command) === selection) {
|
||||
const cwd =
|
||||
process.platform === 'win32' &&
|
||||
path.win32.resolve(path.win32.dirname(binary)).toLowerCase() ===
|
||||
path.win32.resolve(probeCwd).toLowerCase()
|
||||
? probeCwd
|
||||
: undefined
|
||||
// Only a current-directory CLI needs to stay tied to the probe's folder.
|
||||
selection.selected = stats.isFile()
|
||||
? { binary, stamp: commandFileStamp(stats), cwd }
|
||||
: undefined
|
||||
}
|
||||
} catch {
|
||||
// A binary removed during the probe must not become the runtime selection.
|
||||
if (localCommandSelections.get(command) === selection) {
|
||||
delete selection.selected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Native execution reuses preflight's selection without probing or replaying the operation. */
|
||||
export function resolveSelectedLocalCommand(
|
||||
command: string,
|
||||
options: ResolveCommandOptions = {}
|
||||
): string {
|
||||
const selection = localCommandSelections.get(command)
|
||||
if (!selection?.selected || selection.scope !== selectionScope(options)) {
|
||||
return command
|
||||
}
|
||||
if (
|
||||
selection.selected.cwd &&
|
||||
path.win32.resolve(options.cwd ?? process.cwd()).toLowerCase() !==
|
||||
path.win32.resolve(selection.selected.cwd).toLowerCase()
|
||||
) {
|
||||
return command
|
||||
}
|
||||
try {
|
||||
const stats = statSync(selection.selected.binary)
|
||||
if (commandFileStamp(stats) === selection.selected.stamp) {
|
||||
return selection.selected.binary
|
||||
}
|
||||
} catch {
|
||||
// Missing or replaced binaries require a fresh version probe.
|
||||
}
|
||||
delete selection.selected
|
||||
return command
|
||||
}
|
||||
|
||||
async function isExecutableFile(candidate: string, isWin: boolean): Promise<boolean> {
|
||||
try {
|
||||
// Why: stat (not lstat) so symlinked CLIs resolve to their real target.
|
||||
@@ -63,12 +170,15 @@ async function isExecutableFile(candidate: string, isWin: boolean): Promise<bool
|
||||
* which(1)/where.exe lookup, including the current preflight quirk that only
|
||||
* counts matches which resolve to an ABSOLUTE path (so relative PATH entries
|
||||
* and relative command paths stay not-found, exactly as before).
|
||||
*
|
||||
* Stops at the first match; use {@link listLocalCommandPaths} when the rest of
|
||||
* the PATH matters too.
|
||||
*/
|
||||
export async function isCommandOnLocalPath(
|
||||
command: string,
|
||||
options: ResolveCommandOptions = {}
|
||||
): Promise<boolean> {
|
||||
return (await resolveCommandOnLocalPath(command, options)) !== null
|
||||
return (await findLocalCommandPaths(command, options, true)).length > 0
|
||||
}
|
||||
|
||||
/** The absolute path `isCommandOnLocalPath` found, or null. */
|
||||
@@ -76,8 +186,24 @@ export async function resolveCommandOnLocalPath(
|
||||
command: string,
|
||||
options: ResolveCommandOptions = {}
|
||||
): Promise<string | null> {
|
||||
return (await findLocalCommandPaths(command, options, true))[0] ?? null
|
||||
}
|
||||
|
||||
/** Ordered, deduplicated candidates, including executable shims that may fail to run. */
|
||||
export async function listLocalCommandPaths(
|
||||
command: string,
|
||||
options: ResolveCommandOptions = {}
|
||||
): Promise<string[]> {
|
||||
return findLocalCommandPaths(command, options, false)
|
||||
}
|
||||
|
||||
async function findLocalCommandPaths(
|
||||
command: string,
|
||||
options: ResolveCommandOptions,
|
||||
stopAtFirst: boolean
|
||||
): Promise<string[]> {
|
||||
if (!command) {
|
||||
return null
|
||||
return []
|
||||
}
|
||||
const platform = options.platform ?? process.platform
|
||||
const env = options.env ?? process.env
|
||||
@@ -95,6 +221,8 @@ export async function resolveCommandOnLocalPath(
|
||||
const searchDirs = hasPathSeparator ? [''] : isWin ? [cwd, ...pathDirs] : pathDirs
|
||||
const extensions = isWin ? getWindowsExtensions(env, command) : ['']
|
||||
|
||||
const found: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const dir of searchDirs) {
|
||||
for (const ext of extensions) {
|
||||
// Why: forward-slash joins so candidates are statable on every platform
|
||||
@@ -102,13 +230,18 @@ export async function resolveCommandOnLocalPath(
|
||||
const candidate = path.posix.join(dir, command) + ext
|
||||
// Why: preserve the prior `.some(line => path.isAbsolute(line))` filter
|
||||
// over where/which stdout — only absolute resolutions count.
|
||||
if (!isAbsolute(candidate)) {
|
||||
const candidateKey = isWin ? candidate.toLowerCase() : candidate
|
||||
if (!isAbsolute(candidate) || seen.has(candidateKey)) {
|
||||
continue
|
||||
}
|
||||
seen.add(candidateKey)
|
||||
if (await isExecutableFile(candidate, isWin)) {
|
||||
return candidate
|
||||
found.push(candidate)
|
||||
if (stopAtFirst || found.length >= (options.maxResults ?? Infinity)) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
return found
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('listQuickOpenFiles name filter', () => {
|
||||
|
||||
it('counts only matches against the ripgrep cap', async () => {
|
||||
wslAwareSpawnMock
|
||||
.mockImplementationOnce(() => fakeRipgrep('a.ts\nb.ts\nc.ts\nios/AppDelegate.swift\n'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('a.ts\0b.ts\0c.ts\0ios/AppDelegate.swift\0'))
|
||||
.mockImplementationOnce(() => fakeRipgrep(''))
|
||||
|
||||
const files = await listQuickOpenFiles(
|
||||
@@ -80,7 +80,7 @@ describe('listQuickOpenFiles name filter', () => {
|
||||
|
||||
it('rejects with the bundled-ripgrep error when the filtered ignored pass cannot start', async () => {
|
||||
wslAwareSpawnMock
|
||||
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('', null, -2))
|
||||
|
||||
await expect(
|
||||
@@ -98,7 +98,7 @@ describe('listQuickOpenFiles name filter', () => {
|
||||
|
||||
it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => {
|
||||
wslAwareSpawnMock
|
||||
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
|
||||
|
||||
await expect(
|
||||
@@ -116,7 +116,7 @@ describe('listQuickOpenFiles name filter', () => {
|
||||
|
||||
it('still rejects an ignored-pass failure for unfiltered listings', async () => {
|
||||
wslAwareSpawnMock
|
||||
.mockImplementationOnce(() => fakeRipgrep('a.ts\n'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('a.ts\0'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -88,6 +88,33 @@ describe('filesystem-list-files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
|
||||
const child = createMockProcess()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const store: Store = Object.create(null)
|
||||
const promise = listQuickOpenFiles('/repo', store)
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
|
||||
child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
|
||||
if (kind === 'incomplete') {
|
||||
child.emit('close', 0, null)
|
||||
}
|
||||
await expect(promise).rejects.toThrow('not valid UTF-8')
|
||||
if (kind === 'invalid') {
|
||||
expect(child.kill).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('counts NUL-delimited filenames containing newlines as one result each', async () => {
|
||||
const child = createMockProcess()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const store: Store = Object.create(null)
|
||||
const promise = listQuickOpenFiles('/repo', store, undefined, undefined, 2)
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
|
||||
child.stdout?.emit('data', 'first\nsecond.ts\0trailing\r\0third.ts\0')
|
||||
await expect(promise).resolves.toEqual(['first\nsecond.ts', 'trailing\r'])
|
||||
expect(child.kill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a synchronous launch failure before cleanup has been initialized', async () => {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' })
|
||||
@@ -122,7 +149,7 @@ describe('filesystem-list-files', () => {
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts')
|
||||
p1.stdout?.emit('data', 'one.ts\0two.ts')
|
||||
p1.emit('close', 0, null)
|
||||
}, 0)
|
||||
const result = await promise
|
||||
@@ -136,38 +163,60 @@ describe('filesystem-list-files', () => {
|
||||
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version')
|
||||
})
|
||||
|
||||
it('merges normal files and ignored files and filters correctly', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
it('keeps source files first when only a serialized byte budget is provided', async () => {
|
||||
const source = createMockProcess()
|
||||
const broad = createMockProcess()
|
||||
spawnMock.mockImplementation((_command, args: string[]) =>
|
||||
isIgnoredRgPass(args) ? broad : source
|
||||
)
|
||||
const listing = listQuickOpenFiles(
|
||||
'/mock/root',
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization and workspace lookup are mocked above.
|
||||
{} as Store,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
20
|
||||
)
|
||||
await flushMicrotasks()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--no-ignore-vcs')
|
||||
source.stdout?.emit('data', 'source.ts\0')
|
||||
source.emit('close', 0, null)
|
||||
await flushMicrotasks()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock.mock.calls[1]?.[1]).toContain('--no-ignore-vcs')
|
||||
broad.stdout?.emit('data', 'ignored-file.ts\0')
|
||||
await expect(listing).resolves.toEqual(['source.ts'])
|
||||
expect(broad.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
it('lists normal and ignored files with one broad scan and filters correctly', async () => {
|
||||
const p1 = createMockProcess()
|
||||
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
await flushMicrotasks()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(spawnMock.mock.calls[0]?.[1]).toContain('--no-ignore-vcs')
|
||||
|
||||
// Simulate stdout output for normal files
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'node_modules/bad.js\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.git/config\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.github/workflows/ci.yml\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'dir1/') // incomplete line
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'file2.js\n')
|
||||
p1.emit('close', 0, null)
|
||||
p1.stdout?.emit('data', 'file1.ts\0')
|
||||
p1.stdout?.emit('data', 'node_modules/bad.js\0')
|
||||
p1.stdout?.emit('data', '.git/config\0')
|
||||
p1.stdout?.emit('data', '.github/workflows/ci.yml\0')
|
||||
p1.stdout?.emit('data', 'dir1/') // incomplete line
|
||||
p1.stdout?.emit('data', 'file2.js\0')
|
||||
|
||||
// Simulate stdout output for ignored files
|
||||
;(p2.stdout as unknown as EventEmitter).emit('data', '.env.local\n')
|
||||
;(p2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n')
|
||||
;(p2.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') // Duplicate
|
||||
;(p2.stdout as unknown as EventEmitter).emit('data', 'node_modules/ignored.js\n')
|
||||
p2.emit('close', 0, null)
|
||||
// The broad pass includes ignored files too.
|
||||
p1.stdout?.emit('data', '.env.local\0')
|
||||
p1.stdout?.emit('data', 'dist/generated.js\0')
|
||||
p1.stdout?.emit('data', 'file1.ts\0') // Duplicate
|
||||
p1.stdout?.emit('data', 'node_modules/ignored.js\0')
|
||||
p1.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
const result = await promise
|
||||
@@ -183,23 +232,16 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
it('spawns the bundled Linux rg inside the registered WSL runtime for Windows-path worktrees', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('C:\\repo', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
p1.stdout?.emit('data', 'src/index.ts\0')
|
||||
p1.emit('close', 0, null)
|
||||
p2.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
await expect(promise).resolves.toEqual(['src/index.ts'])
|
||||
@@ -214,23 +256,16 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
it('normalizes absolute WSL rg output for Windows-path worktrees', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('C:\\repo', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n')
|
||||
p1.stdout?.emit('data', '/mnt/c/repo/src/index.ts\0')
|
||||
p1.emit('close', 0, null)
|
||||
p2.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
await expect(promise).resolves.toEqual(['src/index.ts'])
|
||||
@@ -238,16 +273,13 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
it('treats a WSL launcher exit 127 as the bundled rg failing to start', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
Object.defineProperty(p1, 'pid', { value: 1 })
|
||||
Object.defineProperty(p2, 'pid', { value: 2 })
|
||||
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const promise = listQuickOpenFiles('C:\\repo', {} as unknown as Store)
|
||||
setTimeout(() => {
|
||||
p1.emit('close', 127, null)
|
||||
p2.emit('close', 0, null)
|
||||
}, 0)
|
||||
|
||||
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
|
||||
@@ -256,48 +288,33 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
it("rejects with the bundled-ripgrep error when a native launcher exits outside ripgrep's contract", async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store)
|
||||
setTimeout(() => p1.emit('close', 127, null), 0)
|
||||
|
||||
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
|
||||
expect(p2.kill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects rg failures instead of resolving a false-empty list', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
p1.emit('close', 2, null)
|
||||
p2.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
await expect(promise).rejects.toThrow('rg exited with code 2')
|
||||
})
|
||||
|
||||
it('kills the sibling rg pass after one pass fails', async () => {
|
||||
it('does not start another scan after the admitted pass fails', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
@@ -308,27 +325,20 @@ describe('filesystem-list-files', () => {
|
||||
}, 10)
|
||||
|
||||
await expect(promise).rejects.toThrow('rg exited with code 2')
|
||||
expect(p2.kill).toHaveBeenCalled()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts rg code 2 when rg emitted parseable paths first', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
p1.stdout?.emit('data', 'src/index.ts\0')
|
||||
p1.emit('close', 2, null)
|
||||
p2.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
await expect(promise).resolves.toEqual(['src/index.ts'])
|
||||
@@ -339,14 +349,8 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
try {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
@@ -355,14 +359,13 @@ describe('filesystem-list-files', () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\npartial')
|
||||
p1.stdout?.emit('data', 'src/index.ts\0partial')
|
||||
const rejection = expect(promise).rejects.toThrow('rg list timed out')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000)
|
||||
|
||||
await rejection
|
||||
expect(p1.kill).toHaveBeenCalled()
|
||||
expect(p2.kill).toHaveBeenCalled()
|
||||
expect((p1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0)
|
||||
expect((p1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0)
|
||||
expect(p1.listenerCount('error')).toBe(0)
|
||||
@@ -374,8 +377,7 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
it('kills local rg scans when a paired listing is cancelled', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
|
||||
spawnMock.mockReturnValue(p1)
|
||||
const controller = new AbortController()
|
||||
const cancellation = new FileListingCancelledError('superseded')
|
||||
const promise = listQuickOpenFiles(
|
||||
@@ -390,34 +392,24 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
await expect(promise).rejects.toBe(cancellation)
|
||||
expect(p1.kill).toHaveBeenCalledOnce()
|
||||
expect(p2.kill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('filters out .next, .cache, .stably, .vscode, .idea', async () => {
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (isIgnoredRgPass(args)) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
spawnMock.mockReturnValue(p1)
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.cache/data.json\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.stably/config.json\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '.idea/workspace.xml\n')
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n')
|
||||
p1.stdout?.emit('data', '.next/cache/1.js\0')
|
||||
p1.stdout?.emit('data', '.cache/data.json\0')
|
||||
p1.stdout?.emit('data', '.stably/config.json\0')
|
||||
p1.stdout?.emit('data', '.vscode/settings.json\0')
|
||||
p1.stdout?.emit('data', '.idea/workspace.xml\0')
|
||||
p1.stdout?.emit('data', 'valid.ts\0')
|
||||
p1.emit('close', 0, null)
|
||||
|
||||
// Empty ignored result
|
||||
p2.emit('close', 0, null)
|
||||
}, 10)
|
||||
|
||||
const result = await promise
|
||||
@@ -451,7 +443,7 @@ describe('filesystem-list-files', () => {
|
||||
})
|
||||
|
||||
describe('when the bundled rg cannot start', () => {
|
||||
it('kills only the admitted pass when ignored rg fails before spawn', async () => {
|
||||
it('does not kill a process that failed before receiving a pid', async () => {
|
||||
const primary = createMockProcess()
|
||||
const missingIgnored = createMockProcess()
|
||||
Object.defineProperty(missingIgnored, 'pid', { value: undefined })
|
||||
@@ -461,11 +453,11 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store)
|
||||
await flushMicrotasks()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
missingIgnored.emit('close', -2, null)
|
||||
|
||||
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
|
||||
expect(primary.kill).toHaveBeenCalled()
|
||||
expect(primary.kill).not.toHaveBeenCalled()
|
||||
expect(missingIgnored.kill).not.toHaveBeenCalled()
|
||||
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
|
||||
expect(() => missingIgnored.emit('error', error)).not.toThrow()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode'
|
||||
import { RipgrepFilenameDecoder, RipgrepFilenameError } from '../../shared/ripgrep-filename-decoder'
|
||||
import { sep } from 'node:path'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type { Store } from '../persistence'
|
||||
@@ -8,7 +10,6 @@ import {
|
||||
buildExcludePathPrefixes,
|
||||
buildRgArgsForQuickOpen,
|
||||
normalizeQuickOpenRgLine,
|
||||
type RgOutputMode,
|
||||
shouldExcludeQuickOpenRelPath,
|
||||
shouldIncludeQuickOpenPath
|
||||
} from '../../shared/quick-open-filter'
|
||||
@@ -79,6 +80,10 @@ export async function listQuickOpenFiles(
|
||||
|
||||
const runRg = (args: string[]): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const filenameDecoder = new RipgrepFilenameDecoder((error) => {
|
||||
killSpawnedRipgrepProcess(child)
|
||||
finish(error)
|
||||
}, Boolean(wslDistroForOutput))
|
||||
let buf = ''
|
||||
let done = false
|
||||
let parseablePathCount = 0
|
||||
@@ -138,18 +143,22 @@ export async function listQuickOpenFiles(
|
||||
return
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const handleStdoutData = (chunk: string): void => {
|
||||
buf += chunk
|
||||
const handleStdoutData = (chunk: Buffer | string): void => {
|
||||
const decoded = filenameDecoder.decode(chunk)
|
||||
if (decoded === null) {
|
||||
return
|
||||
}
|
||||
buf += decoded
|
||||
let start = 0
|
||||
let newlineIdx = buf.indexOf('\n', start)
|
||||
while (newlineIdx !== -1) {
|
||||
if (processLine(buf.substring(start, newlineIdx))) {
|
||||
let delimiterIdx = buf.indexOf('\0', start)
|
||||
while (delimiterIdx !== -1) {
|
||||
if (processLine(buf.substring(start, delimiterIdx))) {
|
||||
buf = ''
|
||||
finishAtLimit()
|
||||
return
|
||||
}
|
||||
start = newlineIdx + 1
|
||||
newlineIdx = buf.indexOf('\n', start)
|
||||
start = delimiterIdx + 1
|
||||
delimiterIdx = buf.indexOf('\0', start)
|
||||
}
|
||||
buf = start < buf.length ? buf.substring(start) : ''
|
||||
}
|
||||
@@ -211,14 +220,15 @@ export async function listQuickOpenFiles(
|
||||
finish(new Error(`rg killed by ${signal}`))
|
||||
return
|
||||
}
|
||||
if (!filenameDecoder.finish()) {
|
||||
return
|
||||
}
|
||||
if (buf && processLine(buf)) {
|
||||
buf = ''
|
||||
finishAtLimit()
|
||||
return
|
||||
}
|
||||
if (code === 0 || code === 1) {
|
||||
finish()
|
||||
} else if (code === 2 && parseablePathCount > 0) {
|
||||
if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) {
|
||||
// rg can return 2 for unreadable subdirectories while still listing
|
||||
// usable files from the rest of the root.
|
||||
finish()
|
||||
@@ -257,7 +267,6 @@ export async function listQuickOpenFiles(
|
||||
|
||||
children.push({ child, isDone: () => done, finish })
|
||||
|
||||
child.stdout?.setEncoding('utf-8')
|
||||
child.stdout?.on('data', handleStdoutData)
|
||||
child.stderr?.on('data', handleStderrData)
|
||||
child.once('error', handleError)
|
||||
@@ -277,9 +286,7 @@ export async function listQuickOpenFiles(
|
||||
}
|
||||
|
||||
const killSurvivors = (): void => {
|
||||
// Why: if one rg pass fails, Promise.all rejects immediately while the
|
||||
// sibling scan can keep walking a huge tree until timeout. Stop it so
|
||||
// repeated Quick Open attempts do not accumulate local rg processes.
|
||||
// Failed listings must release any process still walking the tree.
|
||||
for (const entry of children) {
|
||||
if (entry.isDone()) {
|
||||
continue
|
||||
@@ -292,34 +299,29 @@ export async function listQuickOpenFiles(
|
||||
}
|
||||
|
||||
function finishAtLimit(): void {
|
||||
for (const entry of children) {
|
||||
if (entry.isDone()) {
|
||||
continue
|
||||
}
|
||||
entry.finish()
|
||||
if (entry.child.exitCode === null && entry.child.signalCode === null) {
|
||||
killSpawnedRipgrepProcess(entry.child)
|
||||
}
|
||||
}
|
||||
killSurvivors()
|
||||
}
|
||||
|
||||
try {
|
||||
const primaryRun = runRg(primary)
|
||||
if (maxResults === undefined && maxSerializedBytes === undefined) {
|
||||
// Why: a pid-less primary proves launch failure; avoid doubling the failed spawn.
|
||||
await (children[0]?.child.pid === undefined
|
||||
? primaryRun
|
||||
: Promise.all([primaryRun, runRg(ignoredPass)]))
|
||||
// The broader pass already includes source files; an unbounded listing needs only one scan.
|
||||
await runRg(ignoredPass)
|
||||
} else {
|
||||
// Why: ignored-file output can be much larger and faster than the primary pass; let source
|
||||
// files claim every bounded autocomplete budget first, including the transport byte cap.
|
||||
await primaryRun
|
||||
await runRg(primary)
|
||||
if (
|
||||
(maxResults === undefined || files.size < maxResults) &&
|
||||
(maxSerializedBytes === undefined || serializedBytes < maxSerializedBytes)
|
||||
) {
|
||||
// Why: a filtered scan walks the whole tree; an ignored-pass timeout keeps primary matches.
|
||||
await runRg(ignoredPass).catch((err: unknown) => {
|
||||
if (!pathFilter || signal?.aborted || err instanceof RipgrepUnavailableError) {
|
||||
if (
|
||||
!pathFilter ||
|
||||
signal?.aborted ||
|
||||
err instanceof RipgrepUnavailableError ||
|
||||
err instanceof RipgrepFilenameError
|
||||
) {
|
||||
throw err
|
||||
}
|
||||
})
|
||||
@@ -334,19 +336,3 @@ export async function listQuickOpenFiles(
|
||||
? result
|
||||
: limitQuickOpenFilesBySerializedBytes(result, maxSerializedBytes)
|
||||
}
|
||||
|
||||
function getQuickOpenRgOutputMode(
|
||||
rawLine: string,
|
||||
translatedLine: string,
|
||||
rootPath: string
|
||||
): RgOutputMode {
|
||||
if (
|
||||
translatedLine !== rawLine ||
|
||||
rawLine.startsWith('/') ||
|
||||
/^[A-Za-z]:[\\/]/.test(rawLine) ||
|
||||
rawLine.startsWith('\\\\')
|
||||
) {
|
||||
return { kind: 'absolute', rootPath }
|
||||
}
|
||||
return { kind: 'cwd-relative' }
|
||||
}
|
||||
|
||||
@@ -81,6 +81,64 @@ describe('searchQuickOpenFilePaths', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves cancellation while an incomplete UTF-8 scalar is buffered', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
const controller = new AbortController()
|
||||
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
|
||||
query: 'file',
|
||||
limit: 2,
|
||||
signal: controller.signal
|
||||
})
|
||||
await flushMicrotasks()
|
||||
child.stdout?.emit('data', Buffer.from([0xf0, 0x9f]))
|
||||
controller.abort()
|
||||
await expect(promise).rejects.toSatisfy(isFileListingCancellation)
|
||||
})
|
||||
|
||||
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { query: 'file', limit: 2 })
|
||||
await flushMicrotasks()
|
||||
child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
|
||||
if (kind === 'incomplete') {
|
||||
child.emit('close', 0, null)
|
||||
}
|
||||
await expect(promise).rejects.toThrow('not valid UTF-8')
|
||||
if (kind === 'invalid') {
|
||||
expect(child.kill).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves control characters within ranked paths', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
|
||||
query: 'target',
|
||||
limit: 2
|
||||
})
|
||||
await flushMicrotasks()
|
||||
child.stdout?.emit('data', 'first\ntarget.ts\0target.ts\r\0')
|
||||
child.emit('close', 0, null)
|
||||
expect((await promise).paths.sort()).toEqual(['first\ntarget.ts', 'target.ts\r'].sort())
|
||||
})
|
||||
|
||||
it('rejects and stops an oversized path rather than ranking a truncated suffix', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
|
||||
query: 'target',
|
||||
limit: 2
|
||||
})
|
||||
await flushMicrotasks()
|
||||
child.stdout?.emit('data', 'x'.repeat(64 * 1024 + 1))
|
||||
await expect(promise).rejects.toThrow('file path exceeds the listing limit')
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
child.stdout?.emit('data', 'target.ts\0')
|
||||
child.emit('close', 0, null)
|
||||
})
|
||||
|
||||
it('finds fuzzy matches after 100k paths without returning excluded worktrees', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
@@ -94,14 +152,11 @@ describe('searchQuickOpenFilePaths', () => {
|
||||
expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(wslAwareSpawnMock.mock.calls[0][0]).toBe('/bundled/rg')
|
||||
expect(wslAwareSpawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs')
|
||||
;(child.stdout as unknown as EventEmitter).emit(
|
||||
child.stdout?.emit(
|
||||
'data',
|
||||
`${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n`
|
||||
)
|
||||
;(child.stdout as unknown as EventEmitter).emit(
|
||||
'data',
|
||||
'nested/src/sta-4354-target.ts\nsrc/sta-4354-target.ts\n'
|
||||
`${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0`
|
||||
)
|
||||
child.stdout?.emit('data', 'nested/src/sta-4354-target.ts\0src/sta-4354-target.ts\0')
|
||||
child.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
@@ -138,7 +193,7 @@ describe('searchQuickOpenFilePaths', () => {
|
||||
limit: 32
|
||||
})
|
||||
await flushMicrotasks()
|
||||
;(child.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n')
|
||||
child.stdout?.emit('data', 'src/target.ts\0')
|
||||
child.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] })
|
||||
@@ -164,10 +219,7 @@ describe('searchQuickOpenFilePaths', () => {
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2)
|
||||
;(succeeded.stdout as unknown as EventEmitter).emit(
|
||||
'data',
|
||||
'data/chunk-077568/sta-4354-gitignored-target.bin\n'
|
||||
)
|
||||
succeeded.stdout?.emit('data', 'data/chunk-077568/sta-4354-gitignored-target.bin\0')
|
||||
succeeded.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
@@ -189,7 +241,7 @@ describe('searchQuickOpenFilePaths', () => {
|
||||
limit: 32
|
||||
})
|
||||
await flushMicrotasks()
|
||||
;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n')
|
||||
succeeded.stdout?.emit('data', 'src/target.ts\0')
|
||||
succeeded.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] })
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode'
|
||||
import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder'
|
||||
import { sep } from 'node:path'
|
||||
import type { Store } from '../persistence'
|
||||
import { fileListingCancellationError } from '../../shared/file-listing-cancellation'
|
||||
@@ -6,8 +8,7 @@ import {
|
||||
buildRgArgsForQuickOpen,
|
||||
normalizeQuickOpenRgLine,
|
||||
shouldExcludeQuickOpenRelPath,
|
||||
shouldIncludeQuickOpenPath,
|
||||
type RgOutputMode
|
||||
shouldIncludeQuickOpenPath
|
||||
} from '../../shared/quick-open-filter'
|
||||
import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search'
|
||||
import {
|
||||
@@ -110,7 +111,11 @@ function scanRipgrepPaths(args: {
|
||||
return Promise.reject(fileListingCancellationError(args.signal))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0x0a)
|
||||
const filenameDecoder = new RipgrepFilenameDecoder((error) => {
|
||||
killSpawnedRipgrepProcess(child)
|
||||
finish(error)
|
||||
}, Boolean(args.wslDistroForOutput))
|
||||
const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0)
|
||||
let done = false
|
||||
let parseablePathCount = 0
|
||||
let processErrorObserved = false
|
||||
@@ -141,7 +146,7 @@ function scanRipgrepPaths(args: {
|
||||
: rawLine
|
||||
const relPath = normalizeQuickOpenRgLine(
|
||||
translated,
|
||||
getOutputMode(rawLine, translated, args.authorizedRootPath)
|
||||
getQuickOpenRgOutputMode(rawLine, translated, args.authorizedRootPath)
|
||||
)
|
||||
if (relPath === null) {
|
||||
return
|
||||
@@ -178,11 +183,19 @@ function scanRipgrepPaths(args: {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
const handleStdoutData = (chunk: string): void => {
|
||||
pathAccumulator.push(chunk, (path) => {
|
||||
const handleStdoutData = (chunk: Buffer | string): void => {
|
||||
const decoded = filenameDecoder.decode(chunk)
|
||||
if (decoded === null) {
|
||||
return
|
||||
}
|
||||
const result = pathAccumulator.push(decoded, (path) => {
|
||||
processLine(path)
|
||||
return true
|
||||
})
|
||||
if (result === 'path-too-large') {
|
||||
killSpawnedRipgrepProcess(child)
|
||||
finish(new Error('Quick Open file path exceeds the listing limit'))
|
||||
}
|
||||
}
|
||||
const handleStderrData = (): void => {
|
||||
/* drain */
|
||||
@@ -233,6 +246,9 @@ function scanRipgrepPaths(args: {
|
||||
finish(new Error(`rg killed by ${signal}`))
|
||||
return
|
||||
}
|
||||
if (!filenameDecoder.finish()) {
|
||||
return
|
||||
}
|
||||
const trailingPath = pathAccumulator.finish()
|
||||
if (trailingPath) {
|
||||
processLine(trailingPath)
|
||||
@@ -249,7 +265,6 @@ function scanRipgrepPaths(args: {
|
||||
finish(fileListingCancellationError(args.signal))
|
||||
}
|
||||
|
||||
child.stdout?.setEncoding('utf-8')
|
||||
child.stdout?.on('data', handleStdoutData)
|
||||
child.stderr?.on('data', handleStderrData)
|
||||
child.once('error', handleError)
|
||||
@@ -265,12 +280,3 @@ function scanRipgrepPaths(args: {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getOutputMode(rawLine: string, translatedLine: string, rootPath: string): RgOutputMode {
|
||||
return translatedLine !== rawLine ||
|
||||
rawLine.startsWith('/') ||
|
||||
/^[A-Za-z]:[\\/]/.test(rawLine) ||
|
||||
rawLine.startsWith('\\\\')
|
||||
? { kind: 'absolute', rootPath }
|
||||
: { kind: 'cwd-relative' }
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('filesystem rg search timeout', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps post-spawn errors on the existing empty-result path', async () => {
|
||||
it('rejects post-spawn errors instead of returning an empty result', async () => {
|
||||
const child = createMockProcess()
|
||||
Object.defineProperty(child, 'pid', { value: 1 })
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
@@ -212,7 +212,7 @@ describe('filesystem rg search timeout', () => {
|
||||
await flushMicrotasks()
|
||||
child.emit('error', new Error('post-spawn failure'))
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ files: [] })
|
||||
await expect(promise).rejects.toThrow('post-spawn failure')
|
||||
})
|
||||
|
||||
// Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler
|
||||
@@ -298,6 +298,34 @@ describe('filesystem rg search timeout', () => {
|
||||
expect(wslAwareSpawnMock.mock.calls[0]?.[0]).toBe('/bundled/linux/rg')
|
||||
})
|
||||
|
||||
it('marks WSL filenames that UNC cannot represent as incomplete', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all store access is mocked for this handler.
|
||||
registerFilesystemHandlers({} as never)
|
||||
const promise = handlers.get('fs:search')!(
|
||||
{ sender: { id: 7 } },
|
||||
{ rootPath: 'C:\\repo', query: 'hello' }
|
||||
)
|
||||
await flushMicrotasks()
|
||||
child.stdout?.emit(
|
||||
'data',
|
||||
`${JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: './a\\b.txt' },
|
||||
lines: { text: 'hello\n' },
|
||||
line_number: 1,
|
||||
submatches: [{ start: 0, end: 5 }]
|
||||
}
|
||||
})}\n`
|
||||
)
|
||||
child.emit('close', 0, null)
|
||||
await expect(promise).resolves.toMatchObject({ files: [], truncated: true })
|
||||
expect(toWindowsWslPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('translates WSL rg output for Windows-path project search results', async () => {
|
||||
const child = createMockProcess()
|
||||
wslAwareSpawnMock.mockReturnValue(child)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RipgrepSearchDiagnostics } from '../../../shared/ripgrep-search-diagnostics'
|
||||
import { SearchSubprocessLineAccumulator } from '../../../shared/search-subprocess-lines'
|
||||
import { ipcMain } from 'electron'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
@@ -65,7 +66,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro
|
||||
|
||||
return new Promise<SearchResult>((resolvePromise, rejectPromise) => {
|
||||
const rgArgs = buildRgArgs(args.query, rootPath, args)
|
||||
const rgArgs = buildRgArgs(args.query, '.', args)
|
||||
// Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on.
|
||||
const previousChild = activeTextSearches.get(searchKey)
|
||||
if (previousChild) {
|
||||
@@ -73,7 +74,8 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
}
|
||||
|
||||
const acc = createAccumulator()
|
||||
const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER)
|
||||
const lines = new SearchSubprocessLineAccumulator()
|
||||
const diagnostics = new RipgrepSearchDiagnostics()
|
||||
let resolved = false
|
||||
let processErrorObserved = false
|
||||
let unavailableExitObserved = false
|
||||
@@ -81,8 +83,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
let killTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
const transformAbsPath = wslDistroForOutput
|
||||
? (path: string): string =>
|
||||
path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path
|
||||
? (path: string): string | null =>
|
||||
path.includes('\\')
|
||||
? null
|
||||
: path.startsWith('/')
|
||||
? toWindowsWslPath(path, wslDistroForOutput)
|
||||
: path
|
||||
: undefined
|
||||
|
||||
const finish = (result: SearchResult | PromiseLike<SearchResult>): void => {
|
||||
@@ -108,7 +114,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
}
|
||||
resolvePromise(result)
|
||||
}
|
||||
const resolveOnce = (): void => finish(finalize(acc))
|
||||
const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => {
|
||||
const error = diagnostics.failure(code, signal, acc)
|
||||
finish(error ? Promise.reject(error) : finalize(acc))
|
||||
}
|
||||
const rejectUnavailable = (): void =>
|
||||
finish(Promise.reject(bundledRipgrepUnavailableError()))
|
||||
const processLine = (line: string): void => {
|
||||
@@ -138,10 +147,16 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
activeTextSearches.set(searchKey, nextChild)
|
||||
|
||||
const handleStdoutData = (chunk: string): void => {
|
||||
lines.push(chunk, processLine)
|
||||
if (!lines.push(chunk, processLine)) {
|
||||
acc.truncated = true
|
||||
if (child) {
|
||||
killSpawnedRipgrepProcess(child)
|
||||
}
|
||||
resolveOnce()
|
||||
}
|
||||
}
|
||||
const handleStderrData = (): void => {
|
||||
// Drain stderr so rg cannot block on a full pipe.
|
||||
const handleStderrData = (chunk: Buffer): void => {
|
||||
diagnostics.append(chunk)
|
||||
}
|
||||
const handleError = (error: NodeJS.ErrnoException): void => {
|
||||
processErrorObserved = true
|
||||
@@ -151,18 +166,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
return
|
||||
}
|
||||
if (child && isRipgrepUnavailableExit(child, null, null)) {
|
||||
// Why the cwd check first: spawn reports a missing cwd as ENOENT too, and blaming the
|
||||
// binary for it tells the user to reinstall Orca over a workspace that simply moved.
|
||||
// Why detach close first: a failed spawn emits error THEN close(code < 0), and
|
||||
// close settles synchronously, so this probe would otherwise race it on a sub-ms
|
||||
// margin -- two measurements disagreed on which wins. Detaching makes it deterministic.
|
||||
// Distinguish a missing workspace from a missing binary before close can settle.
|
||||
child.off('close', handleClose)
|
||||
// Why catch: a failed probe must not strand the search; fall back to the prior verdict.
|
||||
void isRipgrepSpawnCwdUsable(rootPath)
|
||||
.catch(() => true)
|
||||
.then((usable) => {
|
||||
// Why re-check: finish() drops its argument once settled, so a rejected promise
|
||||
// built after the close handler already won would go unhandled.
|
||||
// A late rejected promise must not escape after close settles the search.
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
@@ -174,7 +183,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
})
|
||||
return
|
||||
}
|
||||
resolveOnce()
|
||||
finish(Promise.reject(error))
|
||||
if (child) {
|
||||
killSpawnedRipgrepProcess(child)
|
||||
}
|
||||
}
|
||||
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => {
|
||||
// Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise
|
||||
@@ -193,11 +205,11 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
|
||||
rejectUnavailable()
|
||||
return
|
||||
}
|
||||
const tail = lines.finish()
|
||||
const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null
|
||||
if (tail !== null) {
|
||||
processLine(tail)
|
||||
}
|
||||
resolveOnce()
|
||||
resolveOnce(code ?? -1, signal)
|
||||
}
|
||||
|
||||
nextChild.stdout?.setEncoding('utf-8')
|
||||
|
||||
@@ -15,7 +15,14 @@ import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-eve
|
||||
export function registerGitHubIssueMutationHandlers(store: Store): void {
|
||||
ipcMain.handle(
|
||||
'gh:updateIssue',
|
||||
async (event, args: GitHubRepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => {
|
||||
async (
|
||||
event,
|
||||
args: GitHubRepoScopedArgs & {
|
||||
number: number
|
||||
updates: GitHubIssueUpdate
|
||||
ownerRepo?: GitHubOwnerRepo
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) {
|
||||
return { ok: false, error: 'Invalid issue number' }
|
||||
@@ -28,7 +35,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
|
||||
args.number,
|
||||
args.updates,
|
||||
getGitHubRepoConnectionId(repo),
|
||||
...getGitHubLocalGitOptionArgs(store, repo)
|
||||
getGitHubLocalGitOptionArgs(store, repo)[0],
|
||||
repo.issueSourcePreference,
|
||||
...(args.ownerRepo ? [args.ownerRepo] : [])
|
||||
)
|
||||
if (result.ok) {
|
||||
broadcastGitHubWorkItemMutation(
|
||||
@@ -67,7 +76,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
|
||||
args.body.trim(),
|
||||
getGitHubRepoConnectionId(repo),
|
||||
args.prRepo ?? null,
|
||||
...getGitHubLocalGitOptionArgs(store, repo)
|
||||
getGitHubLocalGitOptionArgs(store, repo)[0],
|
||||
// Why: the issue source selector only scopes issues; PR comments keep their resolution.
|
||||
args.type === 'pr' ? undefined : repo.issueSourcePreference
|
||||
)
|
||||
if (result.ok) {
|
||||
broadcastGitHubWorkItemMutation(
|
||||
@@ -84,23 +95,46 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:listLabels', (_event, args: GitHubRepoScopedArgs) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
return listLabels(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
getGitHubRepoConnectionId(repo),
|
||||
...getGitHubLocalGitOptionArgs(store, repo)
|
||||
)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'gh:listLabels',
|
||||
(_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)
|
||||
const connectionId = getGitHubRepoConnectionId(repo)
|
||||
if (args.ownerRepo) {
|
||||
return listLabels(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
connectionId,
|
||||
localGitOptions[0],
|
||||
args.ownerRepo
|
||||
)
|
||||
}
|
||||
return listLabels(repo.path, repo.issueSourcePreference, connectionId, ...localGitOptions)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:listAssignableUsers', (_event, args: GitHubRepoScopedArgs) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
return listAssignableUsers(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
getGitHubRepoConnectionId(repo),
|
||||
...getGitHubLocalGitOptionArgs(store, repo)
|
||||
)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'gh:listAssignableUsers',
|
||||
(_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)
|
||||
const connectionId = getGitHubRepoConnectionId(repo)
|
||||
if (args.ownerRepo) {
|
||||
return listAssignableUsers(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
connectionId,
|
||||
localGitOptions[0],
|
||||
args.ownerRepo
|
||||
)
|
||||
}
|
||||
return listAssignableUsers(
|
||||
repo.path,
|
||||
repo.issueSourcePreference,
|
||||
connectionId,
|
||||
...localGitOptions
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,4 +143,124 @@ describe('registerGitHubHandlers', () => {
|
||||
'origin'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the displayed repository ahead of the current source preference', async () => {
|
||||
harness.repos[0].issueSourcePreference = 'upstream'
|
||||
const ownerRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
|
||||
mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' })
|
||||
getWorkItemDetailsMock.mockResolvedValue(null)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered.
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
await handlers['gh:workItemDetails'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
number: 12,
|
||||
type: 'issue',
|
||||
ownerRepo
|
||||
})
|
||||
await handlers['gh:updateIssue'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
number: 12,
|
||||
updates: { body: 'Origin edit' },
|
||||
ownerRepo
|
||||
})
|
||||
await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo', ownerRepo })
|
||||
await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo', ownerRepo })
|
||||
|
||||
expect(getWorkItemDetailsMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
12,
|
||||
'issue',
|
||||
null,
|
||||
undefined,
|
||||
'upstream',
|
||||
ownerRepo
|
||||
)
|
||||
expect(mocks.client.updateIssue).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
12,
|
||||
{ body: 'Origin edit' },
|
||||
null,
|
||||
undefined,
|
||||
'upstream',
|
||||
ownerRepo
|
||||
)
|
||||
expect(mocks.client.listLabels).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'upstream',
|
||||
null,
|
||||
undefined,
|
||||
ownerRepo
|
||||
)
|
||||
expect(mocks.client.listAssignableUsers).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'upstream',
|
||||
null,
|
||||
undefined,
|
||||
ownerRepo
|
||||
)
|
||||
})
|
||||
|
||||
it('pins the repo origin source preference on issue update and comment IPC', async () => {
|
||||
harness.repos = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
issueSourcePreference: 'origin'
|
||||
}
|
||||
]
|
||||
mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' })
|
||||
mocks.client.addIssueComment.mockResolvedValue({ ok: false, error: 'stub' })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered.
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
await handlers['gh:updateIssue'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
number: 5,
|
||||
updates: { body: 'Updated' }
|
||||
})
|
||||
await handlers['gh:addIssueComment'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
number: 5,
|
||||
body: 'Issue comment'
|
||||
})
|
||||
await handlers['gh:addIssueComment'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
number: 6,
|
||||
body: 'PR comment',
|
||||
type: 'pr'
|
||||
})
|
||||
|
||||
expect(mocks.client.updateIssue).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
5,
|
||||
{ body: 'Updated' },
|
||||
null,
|
||||
undefined,
|
||||
'origin'
|
||||
)
|
||||
expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/workspace/repo',
|
||||
5,
|
||||
'Issue comment',
|
||||
null,
|
||||
null,
|
||||
undefined,
|
||||
'origin'
|
||||
)
|
||||
expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/workspace/repo',
|
||||
6,
|
||||
'PR comment',
|
||||
null,
|
||||
null,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -146,15 +146,20 @@ export function registerGitHubWorkItemHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
return dispatchWorkItem(
|
||||
args,
|
||||
repo,
|
||||
getWorkItemDetails,
|
||||
getGitHubLocalGitOptionArgs(store, repo)[0]
|
||||
)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'gh:workItemDetails',
|
||||
(_event, args: WorkItemArgs & { ownerRepo?: GitHubOwnerRepo }) => {
|
||||
const repo = assertRegisteredGitHubRepo(args, store)
|
||||
return dispatchWorkItem(
|
||||
args,
|
||||
repo,
|
||||
args.ownerRepo
|
||||
? (...params) => getWorkItemDetails(...params, args.ownerRepo)
|
||||
: getWorkItemDetails,
|
||||
getGitHubLocalGitOptionArgs(store, repo)[0]
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:notifyWorkItemMutated',
|
||||
|
||||
@@ -138,6 +138,15 @@ describe('registerGitHubHandlers', () => {
|
||||
)
|
||||
await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo' })
|
||||
await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo' })
|
||||
const metadataRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
|
||||
await handlers['gh:listLabels'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
ownerRepo: metadataRepo
|
||||
})
|
||||
await handlers['gh:listAssignableUsers'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
ownerRepo: metadataRepo
|
||||
})
|
||||
|
||||
expect(getPRForBranchMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
@@ -197,7 +206,8 @@ describe('registerGitHubHandlers', () => {
|
||||
7,
|
||||
{ body: 'Updated' },
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
undefined
|
||||
)
|
||||
expect(addIssueCommentMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
@@ -205,9 +215,24 @@ describe('registerGitHubHandlers', () => {
|
||||
'Comment',
|
||||
null,
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
undefined
|
||||
)
|
||||
expect(listLabelsMock).toHaveBeenCalledWith('/workspace/repo', undefined, null, localGitOptions)
|
||||
expect(listLabelsMock).toHaveBeenLastCalledWith(
|
||||
'/workspace/repo',
|
||||
undefined,
|
||||
null,
|
||||
localGitOptions,
|
||||
metadataRepo
|
||||
)
|
||||
expect(listAssignableUsersMock).toHaveBeenLastCalledWith(
|
||||
'/workspace/repo',
|
||||
undefined,
|
||||
null,
|
||||
localGitOptions,
|
||||
metadataRepo
|
||||
)
|
||||
expect(listAssignableUsersMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
undefined,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodePath from 'node:path'
|
||||
|
||||
vi.mock('node:path', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodePath>()
|
||||
return { ...actual, extname: actual.win32.extname }
|
||||
})
|
||||
|
||||
import { isMarkdownDocumentName, markdownDocumentFromRelativePath } from './markdown-documents'
|
||||
|
||||
describe('remote Markdown filenames on a Windows client', () => {
|
||||
it('keeps the local filename helper on Windows semantics', () => {
|
||||
expect(isMarkdownDocumentName('notes\\.md')).toBe(false)
|
||||
expect(isMarkdownDocumentName('notes.md\\')).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['notes\\.md', 'a\\b.MDX', '..\\a.markdown', 'nested/README.md'])(
|
||||
'preserves POSIX filename %s and its extension',
|
||||
(relativePath) => {
|
||||
const basename = relativePath.slice(relativePath.lastIndexOf('/') + 1)
|
||||
expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toEqual({
|
||||
filePath: `/home/repo/${relativePath}`,
|
||||
relativePath,
|
||||
basename,
|
||||
name: basename.slice(0, basename.lastIndexOf('.'))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['notes.md\\', 'nested/.md', '../outside.md'])(
|
||||
'rejects non-Markdown or escaping POSIX filename %s',
|
||||
(relativePath) => {
|
||||
expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('normalizes Windows remote separators before reading the basename', () => {
|
||||
expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\README.MD')).toEqual({
|
||||
filePath: 'C:\\repo/notes/README.MD',
|
||||
relativePath: 'notes/README.MD',
|
||||
basename: 'README.MD',
|
||||
name: 'README'
|
||||
})
|
||||
expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\.md')).toBeNull()
|
||||
expect(markdownDocumentFromRelativePath('C:\\repo', '..\\outside.md')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -47,6 +47,24 @@ describe('Markdown document ripgrep lifecycle', () => {
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves timeout when an incomplete UTF-8 scalar is abandoned', async () => {
|
||||
vi.useFakeTimers()
|
||||
const result = listMarkdownDocuments(root)
|
||||
const outcome = expect(result).rejects.toThrow('timed out')
|
||||
child.stdout.write(Buffer.from([0xf0, 0x9f]))
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
await outcome
|
||||
})
|
||||
|
||||
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
|
||||
const result = listMarkdownDocuments(root)
|
||||
child.stdout.write(Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
|
||||
if (kind === 'incomplete') {
|
||||
child.emit('close', 0, null)
|
||||
}
|
||||
await expect(result).rejects.toThrow('not valid UTF-8')
|
||||
})
|
||||
|
||||
it('accepts an empty listing', async () => {
|
||||
const result = listMarkdownDocuments(root)
|
||||
child.emit('close', 1, null)
|
||||
|
||||
@@ -1,7 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { markdownDocumentFromFilePath } from './markdown-documents'
|
||||
import {
|
||||
markdownDocumentFromFilePath,
|
||||
markdownDocumentFromRelativePath
|
||||
} from './markdown-documents'
|
||||
|
||||
describe('markdownDocumentFromFilePath', () => {
|
||||
it.skipIf(process.platform === 'win32')('preserves local literal backslash names', () => {
|
||||
expect(markdownDocumentFromFilePath('/repo\\', '/repo\\/a\\b.md')).toMatchObject({
|
||||
filePath: '/repo\\/a\\b.md',
|
||||
relativePath: 'a\\b.md',
|
||||
basename: 'a\\b.md'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves POSIX remote filenames and trailing root backslashes', () => {
|
||||
for (const name of ['a\\b.md', 'a/b.md', '..\\a.md']) {
|
||||
expect(markdownDocumentFromRelativePath('/repo\\', name)).toMatchObject({
|
||||
filePath: `/repo\\/${name}`,
|
||||
relativePath: name,
|
||||
basename: name.slice(name.lastIndexOf('/') + 1)
|
||||
})
|
||||
}
|
||||
expect(markdownDocumentFromRelativePath('/repo\\', '../a.md')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['C:\\repo\\', '\\\\server\\share\\repo\\'])(
|
||||
'preserves Windows separator handling under %s',
|
||||
(root) => {
|
||||
expect(markdownDocumentFromRelativePath(root, 'a\\b.md')).toMatchObject({
|
||||
filePath: `${root.slice(0, -1)}/a/b.md`,
|
||||
relativePath: 'a/b.md',
|
||||
basename: 'b.md'
|
||||
})
|
||||
expect(markdownDocumentFromRelativePath(root, '..\\a.md')).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps in-root path segments that merely start with parent traversal text', () => {
|
||||
expect(markdownDocumentFromFilePath('/workspace', '/workspace/..notes/file.md')).toMatchObject({
|
||||
filePath: '/workspace/..notes/file.md',
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { normalizeRelativePath } from '../../shared/text-search-paths'
|
||||
import {
|
||||
basename as pathBasename,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
posix,
|
||||
relative,
|
||||
resolve
|
||||
} from 'node:path'
|
||||
import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types'
|
||||
import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn'
|
||||
import { parseWslPath } from '../wsl'
|
||||
@@ -7,36 +18,34 @@ import {
|
||||
ripgrepMissingCwdError
|
||||
} from '../../shared/ripgrep-process-availability'
|
||||
|
||||
function normalizeRelativePath(path: string): string {
|
||||
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
|
||||
export function isMarkdownDocumentName(name: string): boolean {
|
||||
return isMarkdownExtension(extname(name))
|
||||
}
|
||||
|
||||
export function isMarkdownDocumentName(name: string): boolean {
|
||||
const extension = extname(name).toLowerCase()
|
||||
return extension === '.md' || extension === '.mdx' || extension === '.markdown'
|
||||
function isMarkdownExtension(extension: string): boolean {
|
||||
const normalized = extension.toLowerCase()
|
||||
return normalized === '.md' || normalized === '.mdx' || normalized === '.markdown'
|
||||
}
|
||||
|
||||
function basenameFromRelativePath(relativePath: string): string {
|
||||
const normalizedPath = relativePath.replaceAll('\\', '/')
|
||||
return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1)
|
||||
return relativePath.slice(relativePath.lastIndexOf('/') + 1)
|
||||
}
|
||||
|
||||
function isSafeRelativePath(relativePath: string): boolean {
|
||||
return !relativePath.split('/').includes('..')
|
||||
}
|
||||
|
||||
function hasParentTraversalSegment(relativePath: string): boolean {
|
||||
return relativePath.split(/[\\/]+/).includes('..')
|
||||
}
|
||||
|
||||
function rootRelativePath(rootPath: string, filePath: string): string | null {
|
||||
const resolvedRoot = resolve(rootPath)
|
||||
const resolvedFile = resolve(filePath)
|
||||
const relativePath = relative(resolvedRoot, resolvedFile)
|
||||
if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) {
|
||||
if (
|
||||
!isSafeRelativePath(normalizeRelativePath(relativePath, rootPath)) ||
|
||||
isAbsolute(relativePath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return normalizeRelativePath(relativePath)
|
||||
return normalizeRelativePath(relativePath, rootPath)
|
||||
}
|
||||
|
||||
export function fileDocumentFromFilePath(
|
||||
@@ -50,7 +59,7 @@ export function fileDocumentFromFilePath(
|
||||
rootRelativePath(rootPath, filePath) ??
|
||||
(options.outsideRootRelativePath === 'basename'
|
||||
? basename
|
||||
: normalizeRelativePath(relative(rootPath, filePath)))
|
||||
: normalizeRelativePath(relative(rootPath, filePath), rootPath))
|
||||
return {
|
||||
filePath,
|
||||
relativePath,
|
||||
@@ -65,18 +74,22 @@ export function markdownDocumentFromRelativePath(
|
||||
rootPath: string,
|
||||
relativePath: string
|
||||
): MarkdownDocument | null {
|
||||
const normalizedRelativePath = normalizeRelativePath(relativePath)
|
||||
const normalizedRelativePath = normalizeRelativePath(relativePath, rootPath)
|
||||
// Why: SSH providers should return root-relative paths; reject escape
|
||||
// segments before building a synthetic absolute path for renderer use.
|
||||
if (!isSafeRelativePath(normalizedRelativePath)) {
|
||||
return null
|
||||
}
|
||||
const basename = basenameFromRelativePath(normalizedRelativePath)
|
||||
if (!isMarkdownDocumentName(basename)) {
|
||||
// Remote separators are already normalized; a POSIX backslash stays part of the name.
|
||||
const extension = posix.extname(basename)
|
||||
if (!isMarkdownExtension(extension)) {
|
||||
return null
|
||||
}
|
||||
const extension = extname(basename)
|
||||
const normalizedRoot = rootPath.replace(/[\\/]+$/, '')
|
||||
const normalizedRoot = rootPath.replace(
|
||||
isWindowsAbsolutePathLike(rootPath) ? /[\\/]+$/ : /\/+$/,
|
||||
''
|
||||
)
|
||||
return {
|
||||
filePath: `${normalizedRoot}/${normalizedRelativePath}`,
|
||||
relativePath: normalizedRelativePath,
|
||||
@@ -111,9 +124,9 @@ export async function listMarkdownDocuments(
|
||||
'--null',
|
||||
'--path-separator',
|
||||
'/',
|
||||
// Directory-only globs preserve hidden Markdown files without traversing hidden folders.
|
||||
// Keep case variants in --glob: --iglob is applied after exclusions and can reopen hidden folders.
|
||||
'--glob',
|
||||
'**',
|
||||
'*.{[mM][dD],[mM][dD][xX],[mM][aA][rR][kK][dD][oO][wW][nN]}',
|
||||
'--glob',
|
||||
'!**/.*/',
|
||||
'--glob',
|
||||
@@ -131,6 +144,10 @@ export async function listMarkdownDocuments(
|
||||
)
|
||||
|
||||
return new Promise((resolveListing, reject) => {
|
||||
const filenameDecoder = new RipgrepFilenameDecoder(
|
||||
(error) => finish(error),
|
||||
Boolean(parseWslPath(rootPath)?.distro ?? options.wslDistro)
|
||||
)
|
||||
const documents: MarkdownDocument[] = []
|
||||
let carry = ''
|
||||
let stderr = ''
|
||||
@@ -172,8 +189,12 @@ export async function listMarkdownDocuments(
|
||||
const onStderr = (chunk: string): void => {
|
||||
stderr = (stderr + chunk).slice(0, 4096)
|
||||
}
|
||||
const onData = (chunk: string): void => {
|
||||
carry += chunk
|
||||
const onData = (chunk: Buffer | string): void => {
|
||||
const decoded = filenameDecoder.decode(chunk)
|
||||
if (decoded === null) {
|
||||
return
|
||||
}
|
||||
carry += decoded
|
||||
let start = 0
|
||||
let end: number
|
||||
while ((end = carry.indexOf('\0', start)) !== -1) {
|
||||
@@ -201,10 +222,11 @@ export async function listMarkdownDocuments(
|
||||
finish(ripgrepMissingCwdError(rootPath))
|
||||
} else if (signal || (code !== 0 && code !== 1)) {
|
||||
finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`))
|
||||
} else if (carry) {
|
||||
finish(new Error('Incomplete path in Markdown document listing'))
|
||||
} else {
|
||||
finish()
|
||||
if (!filenameDecoder.finish()) {
|
||||
return
|
||||
}
|
||||
finish(carry ? new Error('Incomplete path in Markdown document listing') : undefined)
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(
|
||||
@@ -212,7 +234,6 @@ export async function listMarkdownDocuments(
|
||||
MARKDOWN_LISTING_TIMEOUT_MS
|
||||
)
|
||||
timer.unref?.()
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stderr?.setEncoding('utf8')
|
||||
child.stdout?.on('data', onData)
|
||||
child.stderr?.on('data', onStderr)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as LocalCommandResolver from './command-path-resolver'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
@@ -12,6 +13,7 @@ const {
|
||||
getGiteaAuthStatusMock,
|
||||
resolveCliCommandsMock,
|
||||
isCommandOnLocalPathMock,
|
||||
listLocalCommandPathsMock,
|
||||
mergePersistedWindowsPathAsyncMock,
|
||||
mergePersistedWindowsPathMock
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -26,6 +28,7 @@ const {
|
||||
getGiteaAuthStatusMock: vi.fn(),
|
||||
resolveCliCommandsMock: vi.fn(),
|
||||
isCommandOnLocalPathMock: vi.fn(),
|
||||
listLocalCommandPathsMock: vi.fn(),
|
||||
mergePersistedWindowsPathAsyncMock: vi.fn(),
|
||||
mergePersistedWindowsPathMock: vi.fn()
|
||||
}))
|
||||
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
|
||||
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
|
||||
// These tests express "which commands are on PATH" via the where/which mock,
|
||||
// so route the resolver through that same mock to preserve their intent.
|
||||
vi.mock('./command-path-resolver', () => ({
|
||||
isCommandOnLocalPath: isCommandOnLocalPathMock
|
||||
vi.mock('./command-path-resolver', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof LocalCommandResolver>()),
|
||||
isCommandOnLocalPath: isCommandOnLocalPathMock,
|
||||
listLocalCommandPaths: listLocalCommandPathsMock
|
||||
}))
|
||||
|
||||
vi.mock('../pty/windows-environment-path', () => ({
|
||||
@@ -113,6 +118,7 @@ describe('preflight', () => {
|
||||
getGiteaAuthStatusMock,
|
||||
resolveCliCommandsMock,
|
||||
isCommandOnLocalPathMock,
|
||||
listLocalCommandPathsMock,
|
||||
mergePersistedWindowsPathAsyncMock,
|
||||
mergePersistedWindowsPathMock
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as LocalCommandResolver from './command-path-resolver'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
@@ -12,6 +13,7 @@ const {
|
||||
getGiteaAuthStatusMock,
|
||||
resolveCliCommandsMock,
|
||||
isCommandOnLocalPathMock,
|
||||
listLocalCommandPathsMock,
|
||||
mergePersistedWindowsPathAsyncMock,
|
||||
mergePersistedWindowsPathMock
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -26,6 +28,7 @@ const {
|
||||
getGiteaAuthStatusMock: vi.fn(),
|
||||
resolveCliCommandsMock: vi.fn(),
|
||||
isCommandOnLocalPathMock: vi.fn(),
|
||||
listLocalCommandPathsMock: vi.fn(),
|
||||
mergePersistedWindowsPathAsyncMock: vi.fn(),
|
||||
mergePersistedWindowsPathMock: vi.fn()
|
||||
}))
|
||||
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
|
||||
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
|
||||
// These tests express "which commands are on PATH" via the where/which mock,
|
||||
// so route the resolver through that same mock to preserve their intent.
|
||||
vi.mock('./command-path-resolver', () => ({
|
||||
isCommandOnLocalPath: isCommandOnLocalPathMock
|
||||
vi.mock('./command-path-resolver', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof LocalCommandResolver>()),
|
||||
isCommandOnLocalPath: isCommandOnLocalPathMock,
|
||||
listLocalCommandPaths: listLocalCommandPathsMock
|
||||
}))
|
||||
|
||||
vi.mock('../pty/windows-environment-path', () => ({
|
||||
@@ -109,6 +114,7 @@ describe('preflight', () => {
|
||||
getGiteaAuthStatusMock,
|
||||
resolveCliCommandsMock,
|
||||
isCommandOnLocalPathMock,
|
||||
listLocalCommandPathsMock,
|
||||
mergePersistedWindowsPathAsyncMock,
|
||||
mergePersistedWindowsPathMock
|
||||
},
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import path from 'node:path'
|
||||
import type * as LocalCommandResolver from './command-path-resolver'
|
||||
import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup'
|
||||
|
||||
const { runPreflightCommandInWslMock } = vi.hoisted(() => ({
|
||||
runPreflightCommandInWslMock: vi.fn()
|
||||
const {
|
||||
runPreflightCommandInWslMock,
|
||||
execFileAsyncMock,
|
||||
listLocalCommandPathsMock,
|
||||
runProcessMock
|
||||
} = vi.hoisted(() => ({
|
||||
runPreflightCommandInWslMock: vi.fn(),
|
||||
execFileAsyncMock: vi.fn(),
|
||||
listLocalCommandPathsMock: vi.fn(),
|
||||
runProcessMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
|
||||
|
||||
vi.mock('./preflight-local-env', () => ({ buildLocalPreflightEnv: () => undefined }))
|
||||
|
||||
vi.mock('./preflight-wsl-command', () => ({
|
||||
runPreflightCommandInWsl: runPreflightCommandInWslMock
|
||||
}))
|
||||
|
||||
import { isCommandOnPath } from './preflight-command-exec'
|
||||
vi.mock('./command-path-resolver', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof LocalCommandResolver>()),
|
||||
isCommandOnLocalPath: vi.fn(async () => false),
|
||||
listLocalCommandPaths: listLocalCommandPathsMock
|
||||
}))
|
||||
|
||||
// Why: `findRunnableLocalCommand` decides what to spawn next from the error
|
||||
// `execFile` puts on a failed probe, so the shapes below have to be its own.
|
||||
vi.mock('child_process', () => {
|
||||
const execFileWithPromisify = Object.assign(vi.fn(), {
|
||||
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
|
||||
})
|
||||
return { execFile: execFileWithPromisify, spawn: vi.fn() }
|
||||
})
|
||||
|
||||
import { findRunnableLocalCommand, isCommandOnPath } from './preflight-command-exec'
|
||||
|
||||
describe('isCommandOnPath', () => {
|
||||
const sentinel = '__ORCA_PREFLIGHT_COMMAND_PATH__'
|
||||
@@ -59,3 +88,222 @@ describe('isCommandOnPath', () => {
|
||||
await expect(isCommandOnPath('codex', { distro: 'Ubuntu' })).resolves.toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findRunnableLocalCommand', () => {
|
||||
const shim = '/Users/tester/.asdf/shims/gh'
|
||||
const second = '/Users/tester/.volta/bin/gh'
|
||||
const third = '/usr/local/bin/gh'
|
||||
|
||||
const spawnedCommands = () => execFileAsyncMock.mock.calls.map(([command]) => command)
|
||||
|
||||
let pathBefore = ''
|
||||
|
||||
beforeEach(() => {
|
||||
pathBefore = process.env.PATH ?? ''
|
||||
execFileAsyncMock.mockReset()
|
||||
runProcessMock.mockReset()
|
||||
listLocalCommandPathsMock.mockReset()
|
||||
listLocalCommandPathsMock.mockResolvedValue([shim, second, third])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.env.PATH = pathBefore
|
||||
})
|
||||
|
||||
it('returns the first copy that runs and leaves the rest alone', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command: string) => {
|
||||
if (command === shim) {
|
||||
throw Object.assign(new Error('cannot execute'), { code: 126 })
|
||||
}
|
||||
return { stdout: 'gh version 2.98.0\n', stderr: '' }
|
||||
})
|
||||
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: second
|
||||
})
|
||||
expect(spawnedCommands()).toEqual([shim, second])
|
||||
})
|
||||
|
||||
it('keeps looking when a copy exits non-zero', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command: string) => {
|
||||
if (command === third) {
|
||||
return { stdout: 'gh version 2.98.0\n', stderr: '' }
|
||||
}
|
||||
throw Object.assign(new Error('Command failed'), { code: 1 })
|
||||
})
|
||||
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: third
|
||||
})
|
||||
})
|
||||
|
||||
it('stops at a timed-out copy instead of paying the timeout once per copy', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command: string) => {
|
||||
if (command === second) {
|
||||
throw Object.assign(new Error('Timed out'), { killed: true, code: null })
|
||||
}
|
||||
throw Object.assign(new Error('cannot execute'), { code: 126 })
|
||||
})
|
||||
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
|
||||
status: 'timeout',
|
||||
binary: second
|
||||
})
|
||||
expect(spawnedCommands()).toEqual([shim, second])
|
||||
})
|
||||
|
||||
it('stops on the ETIMEDOUT shape as well as the killed shape', async () => {
|
||||
execFileAsyncMock.mockImplementation((command: string) =>
|
||||
command === second
|
||||
? Promise.reject(Object.assign(new Error('Timed out'), { code: 'ETIMEDOUT' }))
|
||||
: Promise.reject(Object.assign(new Error('cannot execute'), { code: 126 }))
|
||||
)
|
||||
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
|
||||
status: 'timeout',
|
||||
binary: second
|
||||
})
|
||||
expect(spawnedCommands()).toEqual([shim, second])
|
||||
})
|
||||
|
||||
it('reads a rejection that is not an object as an ordinary failure', async () => {
|
||||
execFileAsyncMock.mockImplementation((command: string) =>
|
||||
command === third
|
||||
? Promise.resolve({ stdout: 'gh version 2.98.0\n', stderr: '' })
|
||||
: Promise.reject('not an error object')
|
||||
)
|
||||
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: third
|
||||
})
|
||||
expect(spawnedCommands()).toEqual([shim, second, third])
|
||||
})
|
||||
|
||||
it('probes the bare command name when fs finds no candidate', async () => {
|
||||
listLocalCommandPathsMock.mockResolvedValue([])
|
||||
execFileAsyncMock.mockResolvedValue({ stdout: 'gh version 2.98.0\n', stderr: '' })
|
||||
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: 'gh'
|
||||
})
|
||||
expect(spawnedCommands()).toEqual(['gh'])
|
||||
})
|
||||
|
||||
it('probes a relative PATH entry as the absolute directory cwd gives it', async () => {
|
||||
const relativeDir = path.join('.', 'tools')
|
||||
process.env.PATH = [shim.replace('/gh', ''), relativeDir].join(path.delimiter)
|
||||
const absoluteDir = path.resolve(relativeDir)
|
||||
const hidden = `${absoluteDir}/gh`
|
||||
const absolutePath = [shim.replace('/gh', ''), absoluteDir].join(path.delimiter)
|
||||
listLocalCommandPathsMock.mockImplementation(
|
||||
async (_command: string, options?: { env?: NodeJS.ProcessEnv }) =>
|
||||
options?.env?.PATH === absolutePath ? [shim, hidden] : []
|
||||
)
|
||||
execFileAsyncMock.mockImplementation(async (command: string) => {
|
||||
if (command === hidden) {
|
||||
return { stdout: 'gh version 2.98.0\n', stderr: '' }
|
||||
}
|
||||
throw Object.assign(new Error('cannot execute'), { code: 126 })
|
||||
})
|
||||
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: hidden
|
||||
})
|
||||
expect(spawnedCommands()).toEqual([shim, hidden])
|
||||
})
|
||||
|
||||
it('does not pay for the bare name when every PATH entry is absolute', async () => {
|
||||
process.env.PATH = ['/Users/tester/.asdf/shims', '/usr/local/bin'].join(path.delimiter)
|
||||
execFileAsyncMock.mockRejectedValue(Object.assign(new Error('cannot execute'), { code: 126 }))
|
||||
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
|
||||
status: 'exec_failed',
|
||||
binary: third
|
||||
})
|
||||
expect(spawnedCommands()).not.toContain('gh')
|
||||
})
|
||||
|
||||
it('runs an explicitly selected Windows cmd shim through the shared runner', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const command = path.win32.join('C:\\tools', 'gh.cmd')
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 0,
|
||||
stdout: 'gh version fixture',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
await expect(findRunnableLocalCommand(command)).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: command
|
||||
})
|
||||
expect(runProcessMock).toHaveBeenCalledWith({
|
||||
program: command,
|
||||
args: ['--version'],
|
||||
env: undefined,
|
||||
timeoutMs: expect.any(Number)
|
||||
})
|
||||
expect(execFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(listLocalCommandPathsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps searching Windows PATH after a broken cmd shim', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const command = path.win32.join('C:\\tools', 'gh.cmd')
|
||||
const working = path.win32.join('C:\\working', 'gh.exe')
|
||||
listLocalCommandPathsMock.mockResolvedValue([command, working])
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 126,
|
||||
stdout: '',
|
||||
stderr: 'broken shim',
|
||||
timedOut: false
|
||||
})
|
||||
execFileAsyncMock.mockResolvedValue({ stdout: 'gh version fixture', stderr: '' })
|
||||
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: working
|
||||
})
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
expect(spawnedCommands()).toEqual([working])
|
||||
})
|
||||
|
||||
it('stops on a shared runner timeout', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const command = path.win32.join('C:\\tools', 'gh.cmd')
|
||||
listLocalCommandPathsMock.mockResolvedValue([command, third])
|
||||
runProcessMock.mockResolvedValue({ code: null, stdout: '', stderr: '', timedOut: true })
|
||||
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
|
||||
status: 'timeout',
|
||||
binary: command
|
||||
})
|
||||
expect(execFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shares the five second timeout across failed candidates', async () => {
|
||||
let now = 1000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
execFileAsyncMock.mockImplementation(async (command: string) => {
|
||||
if (command === shim) {
|
||||
now += 4000
|
||||
throw Object.assign(new Error('cannot execute'), { code: 126 })
|
||||
}
|
||||
throw Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })
|
||||
})
|
||||
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
|
||||
status: 'timeout',
|
||||
binary: second
|
||||
})
|
||||
expect(execFileAsyncMock.mock.calls.map(([, , options]) => options.timeout)).toEqual([
|
||||
5000, 1000
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { promisify } from 'node:util'
|
||||
import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup'
|
||||
import { isCommandOnLocalPath } from './command-path-resolver'
|
||||
import { getSystemCliInstallDirectories } from '../../shared/system-cli-install-dirs'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import {
|
||||
beginLocalCommandSelection,
|
||||
isCommandOnLocalPath,
|
||||
listLocalCommandPaths
|
||||
} from './command-path-resolver'
|
||||
import { buildLocalPreflightEnv } from './preflight-local-env'
|
||||
import { runPreflightCommandInWsl } from './preflight-wsl-command'
|
||||
import type { WslPreflightTarget } from './preflight-wsl-agent-detection'
|
||||
@@ -17,7 +24,11 @@ export function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
async function withPreflightTimeout<T>(command: string, commandPromise: Promise<T>): Promise<T> {
|
||||
async function withPreflightTimeout<T>(
|
||||
command: string,
|
||||
commandPromise: Promise<T>,
|
||||
timeoutMs = PREFLIGHT_COMMAND_TIMEOUT_MS
|
||||
): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
@@ -28,7 +39,7 @@ async function withPreflightTimeout<T>(command: string, commandPromise: Promise<
|
||||
code: 'ETIMEDOUT'
|
||||
})
|
||||
reject(error)
|
||||
}, PREFLIGHT_COMMAND_TIMEOUT_MS)
|
||||
}, timeoutMs)
|
||||
if (typeof timeout.unref === 'function') {
|
||||
timeout.unref()
|
||||
}
|
||||
@@ -47,19 +58,36 @@ async function withPreflightTimeout<T>(command: string, commandPromise: Promise<
|
||||
* docs/reference/wsl-probe-failure-semantics.md before doing so. */
|
||||
export async function execLocalPreflightCommandOrThrow(
|
||||
command: string,
|
||||
args: string[]
|
||||
args: string[],
|
||||
options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}
|
||||
): Promise<PreflightCommandResult> {
|
||||
const env = buildLocalPreflightEnv()
|
||||
const env = options.env ?? buildLocalPreflightEnv()
|
||||
const timeoutMs = options.timeoutMs ?? PREFLIGHT_COMMAND_TIMEOUT_MS
|
||||
// Node cannot execFile a batch shim; the shared runner handles its argv safely.
|
||||
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command)) {
|
||||
const result = await withPreflightTimeout(
|
||||
command,
|
||||
runProcess({ program: command, args, env, timeoutMs }),
|
||||
timeoutMs
|
||||
)
|
||||
if (result.timedOut || result.code !== 0) {
|
||||
throw Object.assign(new Error(`Failed running ${command}`), {
|
||||
...result,
|
||||
code: result.timedOut ? 'ETIMEDOUT' : result.code
|
||||
})
|
||||
}
|
||||
return { stdout: result.stdout, stderr: result.stderr }
|
||||
}
|
||||
const commandPromise = execFileAsync(command, args, {
|
||||
encoding: 'utf-8',
|
||||
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
|
||||
timeout: timeoutMs,
|
||||
// Preflight probes console-subsystem binaries (git, gh, node); without this
|
||||
// each one flashes a console and steals foreground on Windows (#10488).
|
||||
windowsHide: true,
|
||||
...(env ? { env } : {})
|
||||
}) as Promise<PreflightCommandResult>
|
||||
})
|
||||
|
||||
return withPreflightTimeout(command, commandPromise)
|
||||
return withPreflightTimeout(command, commandPromise, timeoutMs)
|
||||
}
|
||||
|
||||
// Throws on any failure — a distro that is booting/unreachable throws the
|
||||
@@ -76,14 +104,106 @@ export async function execCommandInWslOrThrow(
|
||||
return withPreflightTimeout('wsl command', commandPromise)
|
||||
}
|
||||
|
||||
const PREFLIGHT_LOCAL_PROBE_LIMIT = 4
|
||||
|
||||
export type LocalCommandProbe =
|
||||
| { status: 'available'; binary: string }
|
||||
| { status: 'absent' }
|
||||
| { status: 'exec_failed' | 'timeout' | 'limit_reached'; binary: string }
|
||||
|
||||
function probeTimedOut(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
('killed' in error && error.killed === true) || ('code' in error && error.code === 'ETIMEDOUT')
|
||||
)
|
||||
}
|
||||
|
||||
async function localProbeCandidates(
|
||||
command: string,
|
||||
env: NodeJS.ProcessEnv | undefined
|
||||
): Promise<string[]> {
|
||||
const isWin = process.platform === 'win32'
|
||||
const probeEnv = env ?? process.env
|
||||
// Keep relative PATH entries in their original position, as execFile does.
|
||||
const absoluteEnv = isWin
|
||||
? probeEnv
|
||||
: {
|
||||
...probeEnv,
|
||||
PATH: (probeEnv.PATH ?? '')
|
||||
.split(path.delimiter)
|
||||
.map((dir) => path.resolve(dir))
|
||||
.join(path.delimiter)
|
||||
}
|
||||
const maxResults = PREFLIGHT_LOCAL_PROBE_LIMIT + 1
|
||||
const paths = await listLocalCommandPaths(command, { env: absoluteEnv, maxResults })
|
||||
const installPaths =
|
||||
isWin || paths.length >= maxResults
|
||||
? []
|
||||
: await listLocalCommandPaths(command, {
|
||||
env: {
|
||||
PATH: getSystemCliInstallDirectories(process.platform, homedir()).join(path.delimiter)
|
||||
},
|
||||
maxResults
|
||||
})
|
||||
return [...new Set([...paths, ...installPaths])]
|
||||
}
|
||||
|
||||
/** Try only version probes; authentication must stay on the selected binary. */
|
||||
export async function findRunnableLocalCommand(command: string): Promise<LocalCommandProbe> {
|
||||
const publishSelection = beginLocalCommandSelection(command)
|
||||
const result = await probeRunnableLocalCommand(command)
|
||||
await publishSelection(result.status === 'available' ? result.binary : null)
|
||||
return result
|
||||
}
|
||||
|
||||
async function probeRunnableLocalCommand(command: string): Promise<LocalCommandProbe> {
|
||||
const env = buildLocalPreflightEnv()
|
||||
const explicit = command.includes('/') || (process.platform === 'win32' && command.includes('\\'))
|
||||
// An explicit path is the user's selection, even when it cannot run.
|
||||
const candidates = explicit ? [] : await localProbeCandidates(command, env)
|
||||
const probes = candidates.length ? candidates.slice(0, PREFLIGHT_LOCAL_PROBE_LIMIT) : [command]
|
||||
const deadline = Date.now() + PREFLIGHT_COMMAND_TIMEOUT_MS
|
||||
for (const binary of probes) {
|
||||
const timeoutMs = deadline - Date.now()
|
||||
if (timeoutMs <= 0) {
|
||||
return { status: 'timeout', binary }
|
||||
}
|
||||
try {
|
||||
await execLocalPreflightCommandOrThrow(binary, ['--version'], { env, timeoutMs })
|
||||
return { status: 'available', binary }
|
||||
} catch (error) {
|
||||
if (probeTimedOut(error)) {
|
||||
return { status: 'timeout', binary }
|
||||
}
|
||||
if (
|
||||
candidates.length === 0 &&
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT' &&
|
||||
!(await isCommandOnLocalPath(explicit ? path.resolve(command) : command, { env }))
|
||||
) {
|
||||
return { status: 'absent' }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: candidates.length > PREFLIGHT_LOCAL_PROBE_LIMIT ? 'limit_reached' : 'exec_failed',
|
||||
binary: probes.at(-1) ?? command
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCommandAvailable(
|
||||
command: string,
|
||||
wslTarget?: WslPreflightTarget
|
||||
): Promise<boolean> {
|
||||
if (!wslTarget) {
|
||||
return (await findRunnableLocalCommand(command)).status === 'available'
|
||||
}
|
||||
try {
|
||||
await (wslTarget
|
||||
? execCommandInWslOrThrow(wslTarget, `${shellQuote(command)} --version`)
|
||||
: execLocalPreflightCommandOrThrow(command, ['--version']))
|
||||
await execCommandInWslOrThrow(wslTarget, `${shellQuote(command)} --version`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as LocalCommandResolver from './command-path-resolver'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
@@ -13,6 +14,7 @@ const {
|
||||
getGiteaAuthStatusMock,
|
||||
resolveCliCommandsMock,
|
||||
isCommandOnLocalPathMock,
|
||||
listLocalCommandPathsMock,
|
||||
mergePersistedWindowsPathAsyncMock,
|
||||
mergePersistedWindowsPathMock
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -28,6 +30,7 @@ const {
|
||||
getGiteaAuthStatusMock: vi.fn(),
|
||||
resolveCliCommandsMock: vi.fn(),
|
||||
isCommandOnLocalPathMock: vi.fn(),
|
||||
listLocalCommandPathsMock: vi.fn(),
|
||||
mergePersistedWindowsPathAsyncMock: vi.fn(),
|
||||
mergePersistedWindowsPathMock: vi.fn()
|
||||
}))
|
||||
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
|
||||
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
|
||||
// These tests express "which commands are on PATH" via the where/which mock,
|
||||
// so route the resolver through that same mock to preserve their intent.
|
||||
vi.mock('./command-path-resolver', () => ({
|
||||
isCommandOnLocalPath: isCommandOnLocalPathMock
|
||||
vi.mock('./command-path-resolver', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof LocalCommandResolver>()),
|
||||
isCommandOnLocalPath: isCommandOnLocalPathMock,
|
||||
listLocalCommandPaths: listLocalCommandPathsMock
|
||||
}))
|
||||
|
||||
vi.mock('../pty/windows-environment-path', () => ({
|
||||
@@ -115,6 +120,7 @@ describe('preflight', () => {
|
||||
getGiteaAuthStatusMock,
|
||||
resolveCliCommandsMock,
|
||||
isCommandOnLocalPathMock,
|
||||
listLocalCommandPathsMock,
|
||||
mergePersistedWindowsPathAsyncMock,
|
||||
mergePersistedWindowsPathMock
|
||||
},
|
||||
@@ -162,17 +168,29 @@ describe('preflight', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('treats gh as unauthenticated when gh auth status fails without auth markers', async () => {
|
||||
execFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
||||
.mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
|
||||
it.each(['gh', 'glab'])('does not switch %s copies after authentication fails', async (cli) => {
|
||||
const first = `/test/first/${cli}`
|
||||
const second = `/test/second/${cli}`
|
||||
listLocalCommandPathsMock.mockImplementation(async (command: string) =>
|
||||
command === cli ? [first, second] : []
|
||||
)
|
||||
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
|
||||
if (command === first && args[0] === 'auth') {
|
||||
throw Object.assign(new Error('not authenticated'), { code: 1, stderr: 'not logged in' })
|
||||
}
|
||||
return { stdout: 'fixture success', stderr: '' }
|
||||
})
|
||||
|
||||
const status = await runPreflightCheck()
|
||||
|
||||
expect(status.gh).toEqual({ installed: true, authenticated: false })
|
||||
expect(cli === 'gh' ? status.gh : status.glab).toEqual({
|
||||
installed: true,
|
||||
authenticated: false
|
||||
})
|
||||
expect(
|
||||
execFileAsyncMock.mock.calls.filter(([command]) => command === first).map(([, args]) => args)
|
||||
).toEqual([['--version'], ['auth', 'status']])
|
||||
expect(execFileAsyncMock.mock.calls.some(([command]) => command === second)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps older gh stderr success output from showing a false auth warning', async () => {
|
||||
@@ -188,6 +206,88 @@ describe('preflight', () => {
|
||||
expect(status.gh).toEqual({ installed: true, authenticated: true })
|
||||
})
|
||||
|
||||
// Why (#22975): these two cases key the spawn mock on (command, args) instead
|
||||
// of call order, because the fallback probes exactly which copy runs is the
|
||||
// behaviour under test — and `gh auth status` runs in parallel with
|
||||
// `glab auth status`, so a fixed sequence would not be the real one.
|
||||
it('authenticates the gh copy that actually ran, not the shim PATH chose', async () => {
|
||||
const shim = '/Users/octocat/.asdf/shims/gh'
|
||||
const working = '/opt/homebrew/bin/gh'
|
||||
const glabShim = '/Users/octocat/.asdf/shims/glab'
|
||||
const glabWorking = '/usr/local/bin/glab'
|
||||
const shims = new Set([shim, glabShim])
|
||||
listLocalCommandPathsMock.mockImplementation(async (command: string) => {
|
||||
if (command === 'gh') {
|
||||
return [shim, working]
|
||||
}
|
||||
return command === 'glab' ? [glabShim, glabWorking] : []
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
|
||||
if (shims.has(command)) {
|
||||
throw Object.assign(new Error('spawn failed'), { code: 126 })
|
||||
}
|
||||
const auth = args[0] === 'auth'
|
||||
if (command === working) {
|
||||
return { stdout: auth ? 'github.com\n - Active account: true\n' : 'gh version 2.6.0\n' }
|
||||
}
|
||||
if (command === glabWorking) {
|
||||
return { stdout: auth ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n' }
|
||||
}
|
||||
if (command === 'git') {
|
||||
return { stdout: 'git version 2.0.0\n' }
|
||||
}
|
||||
throw new Error(`unexpected command ${command}`)
|
||||
})
|
||||
|
||||
const status = await runPreflightCheck()
|
||||
|
||||
expect(status).toMatchObject({
|
||||
gh: { installed: true, authenticated: true },
|
||||
glab: { installed: true, authenticated: true }
|
||||
})
|
||||
for (const [cli, copy] of [
|
||||
['gh', working],
|
||||
['glab', glabWorking]
|
||||
]) {
|
||||
expect(execFileAsyncMock).toHaveBeenCalledWith(copy, ['auth', 'status'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
windowsHide: true
|
||||
})
|
||||
expect(execFileAsyncMock).not.toHaveBeenCalledWith(cli, ['auth', 'status'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
windowsHide: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('marks gh not installed when no copy PATH offers can run', async () => {
|
||||
const doomed = ['/Users/octocat/.asdf/shims/gh', '/Users/octocat/.local/bin/gh']
|
||||
listLocalCommandPathsMock.mockImplementation(async (command: string) =>
|
||||
command === 'gh' ? doomed : []
|
||||
)
|
||||
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
|
||||
if (doomed.includes(command)) {
|
||||
throw Object.assign(new Error('spawn failed'), { code: 126 })
|
||||
}
|
||||
if (command === 'git') {
|
||||
return { stdout: 'git version 2.0.0\n' }
|
||||
}
|
||||
if (command === 'glab') {
|
||||
return {
|
||||
stdout: args[0] === 'auth' ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n'
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected command ${command}`)
|
||||
})
|
||||
|
||||
const status = await runPreflightCheck()
|
||||
|
||||
expect(status.gh).toEqual({ installed: false, authenticated: false })
|
||||
expect(execFileAsyncMock).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('marks glab as not installed when `glab --version` fails', async () => {
|
||||
execFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { removeTree } from '../../shared/windows-transient-lock-removal'
|
||||
import { ghExecFileAsync } from '../git/command-runner/gh-exec-file'
|
||||
import { glabExecFileAsync } from '../git/command-runner/glab-exec-file'
|
||||
import { execFileCaptureToTermination } from '../git/command-runner/exec-file-capture'
|
||||
import { beginLocalCommandSelection, resolveSelectedLocalCommand } from './command-path-resolver'
|
||||
import {
|
||||
execLocalPreflightCommandOrThrow,
|
||||
findRunnableLocalCommand,
|
||||
shellQuote
|
||||
} from './preflight-command-exec'
|
||||
|
||||
vi.mock('../../shared/system-cli-install-dirs', () => ({
|
||||
getSystemCliInstallDirectories: (_platform: NodeJS.Platform, home: string) => [
|
||||
path.join(home, '.nix-profile', 'bin')
|
||||
]
|
||||
}))
|
||||
|
||||
let root = ''
|
||||
const CLIS = ['gh', 'glab'] as const
|
||||
|
||||
async function fixtureCli(
|
||||
cli: string,
|
||||
label: string,
|
||||
body = `case "$1" in\n--version) echo fixture-version;;\nauth) echo 'Logged in fixture';;\napi) echo '${label}-api';;\nesac\n`
|
||||
): Promise<string> {
|
||||
const dir = path.join(root, label)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const binary = path.join(dir, cli)
|
||||
await writeFile(
|
||||
binary,
|
||||
`#!/bin/sh\nprintf '%s\\n' "$*" >> ${shellQuote(`${binary}-calls`)}\n${body}`,
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
return binary
|
||||
}
|
||||
|
||||
async function calls(binary: string): Promise<string[]> {
|
||||
try {
|
||||
return (await readFile(`${binary}-calls`, 'utf8')).trim().split('\n')
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function providerCommand(cli: 'gh' | 'glab', env?: NodeJS.ProcessEnv, cwd?: string) {
|
||||
const execute = cli === 'gh' ? ghExecFileAsync : glabExecFileAsync
|
||||
return execute(['api', 'user'], { timeout: 1000, idempotent: false, env, cwd })
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'preflight selection in native provider commands',
|
||||
() => {
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-provider-'))
|
||||
vi.stubEnv('HOME', root)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await removeTree(root)
|
||||
})
|
||||
|
||||
it.each(CLIS)(
|
||||
'uses the same runnable %s for version, auth and provider operations',
|
||||
async (cli) => {
|
||||
const shim = await fixtureCli(cli, 'shim', 'echo broken-shim >&2\nexit 126\n')
|
||||
const good = await fixtureCli(cli, 'good')
|
||||
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
|
||||
|
||||
const selected = await findRunnableLocalCommand(cli)
|
||||
expect(selected).toEqual({ status: 'available', binary: good })
|
||||
if (selected.status !== 'available') {
|
||||
throw new Error('Expected the working fixture to be selected')
|
||||
}
|
||||
await expect(
|
||||
execLocalPreflightCommandOrThrow(selected.binary, ['auth', 'status'])
|
||||
).resolves.toMatchObject({ stdout: 'Logged in fixture\n' })
|
||||
await expect(providerCommand(cli, undefined, root)).resolves.toMatchObject({
|
||||
stdout: 'good-api\n'
|
||||
})
|
||||
expect(await calls(shim)).toEqual(['--version'])
|
||||
expect(await calls(good)).toEqual(['--version', 'auth status', 'api user'])
|
||||
}
|
||||
)
|
||||
|
||||
it.each(CLIS)(
|
||||
'keeps the proven %s usable while its version selection refreshes',
|
||||
async (cli) => {
|
||||
const shim = await fixtureCli(cli, 'shim', 'echo broken-shim >&2\nexit 126\n')
|
||||
const good = await fixtureCli(cli, 'good')
|
||||
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
|
||||
await findRunnableLocalCommand(cli)
|
||||
|
||||
const refreshing = findRunnableLocalCommand(cli)
|
||||
await expect(providerCommand(cli)).resolves.toMatchObject({ stdout: 'good-api\n' })
|
||||
await expect(refreshing).resolves.toEqual({ status: 'available', binary: good })
|
||||
expect(await calls(shim)).toEqual(['--version', '--version'])
|
||||
}
|
||||
)
|
||||
|
||||
it('clears the previous selection only after the newest version probe fails', async () => {
|
||||
const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
|
||||
const good = await fixtureCli(
|
||||
'gh',
|
||||
'good',
|
||||
'if [ "$1" = --version ] && [ "$ORCA_22975_VERSION_DISABLED" = 1 ]; then exit 126; fi\necho good-api\n'
|
||||
)
|
||||
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
|
||||
await findRunnableLocalCommand('gh')
|
||||
vi.stubEnv('ORCA_22975_VERSION_DISABLED', '1')
|
||||
|
||||
const refreshing = findRunnableLocalCommand('gh')
|
||||
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'good-api\n' })
|
||||
await expect(refreshing).resolves.toMatchObject({ status: 'exec_failed' })
|
||||
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
|
||||
})
|
||||
|
||||
it.each(CLIS)('also uses the recovered %s from a known install directory', async (cli) => {
|
||||
const shim = await fixtureCli(cli, 'shim', 'exit 126\n')
|
||||
const good = await fixtureCli(cli, path.join('.nix-profile', 'bin'))
|
||||
vi.stubEnv('PATH', path.dirname(shim))
|
||||
|
||||
await expect(findRunnableLocalCommand(cli)).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: good
|
||||
})
|
||||
await expect(providerCommand(cli)).resolves.toMatchObject({
|
||||
stdout: `${path.join('.nix-profile', 'bin')}-api\n`
|
||||
})
|
||||
expect(await calls(shim)).toEqual(['--version'])
|
||||
})
|
||||
|
||||
it.each(CLIS)('keeps %s selected when authentication fails', async (cli) => {
|
||||
const first = await fixtureCli(
|
||||
cli,
|
||||
'first',
|
||||
'if [ "$1" = auth ]; then echo unauthenticated >&2; exit 1; fi\necho first-result\n'
|
||||
)
|
||||
const other = await fixtureCli(cli, 'other')
|
||||
vi.stubEnv('PATH', [path.dirname(first), path.dirname(other)].join(path.delimiter))
|
||||
|
||||
await findRunnableLocalCommand(cli)
|
||||
await expect(
|
||||
execLocalPreflightCommandOrThrow(first, ['auth', 'status'])
|
||||
).rejects.toMatchObject({
|
||||
code: 1
|
||||
})
|
||||
await expect(providerCommand(cli)).resolves.toMatchObject({ stdout: 'first-result\n' })
|
||||
expect(await calls(other)).toEqual([])
|
||||
})
|
||||
|
||||
it.each(CLIS)('never retries a failed %s operation on another binary', async (cli) => {
|
||||
const first = await fixtureCli(
|
||||
cli,
|
||||
'first',
|
||||
'if [ "$1" = api ]; then echo operation-failed >&2; exit 126; fi\necho fixture-version\n'
|
||||
)
|
||||
const other = await fixtureCli(cli, 'other')
|
||||
vi.stubEnv('PATH', [path.dirname(first), path.dirname(other)].join(path.delimiter))
|
||||
|
||||
await findRunnableLocalCommand(cli)
|
||||
await expect(providerCommand(cli)).rejects.toMatchObject({
|
||||
code: 126,
|
||||
stderr: 'operation-failed\n'
|
||||
})
|
||||
expect(await calls(first)).toEqual(['--version', 'api user'])
|
||||
expect(await calls(other)).toEqual([])
|
||||
})
|
||||
|
||||
it('respects a caller PATH and explicit binary instead of the global selection', async () => {
|
||||
const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
|
||||
const good = await fixtureCli('gh', 'good')
|
||||
const custom = await fixtureCli('gh', 'custom')
|
||||
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
|
||||
await findRunnableLocalCommand('gh')
|
||||
const refreshing = findRunnableLocalCommand('gh')
|
||||
|
||||
await expect(
|
||||
providerCommand('gh', { ...process.env, PATH: path.dirname(custom) })
|
||||
).resolves.toMatchObject({ stdout: 'custom-api\n' })
|
||||
await expect(
|
||||
execFileCaptureToTermination(custom, ['api', 'user'], { encoding: 'utf8', timeout: 1000 })
|
||||
).resolves.toMatchObject({ stdout: 'custom-api\n' })
|
||||
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'good-api\n' })
|
||||
await expect(refreshing).resolves.toEqual({ status: 'available', binary: good })
|
||||
})
|
||||
|
||||
it('bypasses a selection after PATH or relative-PATH cwd changes', async () => {
|
||||
const good = await fixtureCli('gh', 'good')
|
||||
const custom = await fixtureCli('gh', 'custom')
|
||||
vi.stubEnv('PATH', path.dirname(good))
|
||||
await findRunnableLocalCommand('gh')
|
||||
const refreshing = findRunnableLocalCommand('gh')
|
||||
vi.stubEnv('PATH', path.dirname(custom))
|
||||
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'custom-api\n' })
|
||||
await refreshing
|
||||
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'custom-api\n' })
|
||||
|
||||
vi.stubEnv('PATH', path.relative(process.cwd(), path.dirname(good)))
|
||||
await findRunnableLocalCommand('gh')
|
||||
expect(resolveSelectedLocalCommand('gh', { cwd: root })).toBe('gh')
|
||||
})
|
||||
|
||||
it('discards a replaced binary and clears a selection when the next probe fails', async () => {
|
||||
const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
|
||||
const good = await fixtureCli('gh', 'good')
|
||||
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
|
||||
await findRunnableLocalCommand('gh')
|
||||
await writeFile(good, '#!/bin/sh\necho changed >&2\nexit 126\n')
|
||||
|
||||
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
|
||||
await expect(findRunnableLocalCommand('gh')).resolves.toMatchObject({ status: 'exec_failed' })
|
||||
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
|
||||
await fixtureCli('gh', 'good')
|
||||
await findRunnableLocalCommand('gh')
|
||||
expect(resolveSelectedLocalCommand('gh')).toBe(good)
|
||||
await rm(good)
|
||||
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
|
||||
})
|
||||
|
||||
it('keeps an older concurrent probe from replacing the newer selection', async () => {
|
||||
const first = await fixtureCli('gh', 'first')
|
||||
const second = await fixtureCli('gh', 'second')
|
||||
const older = beginLocalCommandSelection('gh')
|
||||
const newer = beginLocalCommandSelection('gh')
|
||||
await newer(second)
|
||||
await older(first)
|
||||
await older(null)
|
||||
|
||||
expect(resolveSelectedLocalCommand('gh')).toBe(second)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
describe('Windows selection across working folders', () => {
|
||||
it('keeps an absolute PATH selection in a different provider cwd', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'orca-22975-cwd-'))
|
||||
const binary = path.join(directory, 'gh.CMD')
|
||||
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
await writeFile(binary, '@echo off\r\nexit /b 0\r\n')
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.stubEnv('PATH', directory)
|
||||
const publish = beginLocalCommandSelection('gh')
|
||||
await publish(binary)
|
||||
expect(resolveSelectedLocalCommand('gh', { cwd: path.join(directory, 'project') })).toBe(
|
||||
binary
|
||||
)
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
if (descriptor) {
|
||||
Object.defineProperty(process, 'platform', descriptor)
|
||||
}
|
||||
await removeTree(directory)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not carry a current-directory CLI into another folder', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'orca-22975-local-cwd-'))
|
||||
const binary = path.join(directory, 'gh.CMD')
|
||||
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
await writeFile(binary, '@echo off\r\nexit /b 0\r\n')
|
||||
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(directory)
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.stubEnv('PATH', path.join(directory, 'other'))
|
||||
const publish = beginLocalCommandSelection('gh')
|
||||
await publish(binary)
|
||||
expect(resolveSelectedLocalCommand('gh')).toBe(binary)
|
||||
expect(resolveSelectedLocalCommand('gh', { cwd: path.join(directory, 'project') })).toBe('gh')
|
||||
} finally {
|
||||
cwd.mockRestore()
|
||||
vi.unstubAllEnvs()
|
||||
if (descriptor) {
|
||||
Object.defineProperty(process, 'platform', descriptor)
|
||||
}
|
||||
await removeTree(directory)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe.runIf(process.platform === 'win32')('native provider batch selection', () => {
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-provider-cmd-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await removeTree(root)
|
||||
})
|
||||
|
||||
it.each(CLIS)('passes the selected %s.cmd through the native runner', async (cli) => {
|
||||
const shim = path.join(root, 'shim')
|
||||
const good = path.join(root, 'good')
|
||||
await mkdir(shim)
|
||||
await mkdir(good)
|
||||
await writeFile(path.join(shim, `${cli}.CMD`), '@echo off\r\nexit /b 126\r\n')
|
||||
await writeFile(
|
||||
path.join(good, `${cli}.CMD`),
|
||||
'@echo off\r\nif "%~1"=="api" (echo good-api) else (echo fixture-version)\r\nexit /b 0\r\n'
|
||||
)
|
||||
vi.stubEnv('PATH', [shim, good].join(path.delimiter))
|
||||
vi.stubEnv('Path', [shim, good].join(path.delimiter))
|
||||
vi.stubEnv('PATHEXT', '.CMD')
|
||||
|
||||
await expect(findRunnableLocalCommand(cli)).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: path.posix.join(good, `${cli}.CMD`)
|
||||
})
|
||||
await expect(providerCommand(cli, undefined, root)).resolves.toMatchObject({
|
||||
stdout: expect.stringContaining('good-api')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as LocalCommandResolver from './command-path-resolver'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
@@ -12,6 +13,7 @@ const {
|
||||
getGiteaAuthStatusMock,
|
||||
resolveCliCommandsMock,
|
||||
isCommandOnLocalPathMock,
|
||||
listLocalCommandPathsMock,
|
||||
mergePersistedWindowsPathAsyncMock,
|
||||
mergePersistedWindowsPathMock
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -26,6 +28,7 @@ const {
|
||||
getGiteaAuthStatusMock: vi.fn(),
|
||||
resolveCliCommandsMock: vi.fn(),
|
||||
isCommandOnLocalPathMock: vi.fn(),
|
||||
listLocalCommandPathsMock: vi.fn(),
|
||||
mergePersistedWindowsPathAsyncMock: vi.fn(),
|
||||
mergePersistedWindowsPathMock: vi.fn()
|
||||
}))
|
||||
@@ -58,8 +61,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
|
||||
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
|
||||
// These tests express "which commands are on PATH" via the where/which mock,
|
||||
// so route the resolver through that same mock to preserve their intent.
|
||||
vi.mock('./command-path-resolver', () => ({
|
||||
isCommandOnLocalPath: isCommandOnLocalPathMock
|
||||
vi.mock('./command-path-resolver', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof LocalCommandResolver>()),
|
||||
isCommandOnLocalPath: isCommandOnLocalPathMock,
|
||||
listLocalCommandPaths: listLocalCommandPathsMock
|
||||
}))
|
||||
|
||||
vi.mock('../pty/windows-environment-path', () => ({
|
||||
@@ -106,6 +111,7 @@ describe('preflight', () => {
|
||||
getGiteaAuthStatusMock,
|
||||
resolveCliCommandsMock,
|
||||
isCommandOnLocalPathMock,
|
||||
listLocalCommandPathsMock,
|
||||
mergePersistedWindowsPathAsyncMock,
|
||||
mergePersistedWindowsPathMock
|
||||
},
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { removeTree } from '../../shared/windows-transient-lock-removal'
|
||||
import {
|
||||
execLocalPreflightCommandOrThrow,
|
||||
findRunnableLocalCommand,
|
||||
isCommandAvailable,
|
||||
isCommandOnPath,
|
||||
shellQuote
|
||||
} from './preflight-command-exec'
|
||||
|
||||
const COMMAND = 'orca-22975-gh'
|
||||
const BROKEN_SHIM = 'echo broken-shim >&2\nexit 126\n'
|
||||
let root = ''
|
||||
|
||||
async function cliInDirectory(label: string, body = 'echo gh-version-fixture\n'): Promise<string> {
|
||||
const dir = path.join(root, label)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(dir, COMMAND),
|
||||
`#!/bin/sh\nprintf '%s\\n' "$@" >> ${shellQuote(path.join(dir, 'argv.txt'))}\n${body}`,
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function probedArgs(dir: string): Promise<string[]> {
|
||||
try {
|
||||
return (await readFile(path.join(dir, 'argv.txt'), 'utf8')).split('\n').filter(Boolean)
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'local CLI version probes with real processes',
|
||||
() => {
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-case-'))
|
||||
vi.stubEnv('HOME', root)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await removeTree(root)
|
||||
})
|
||||
|
||||
it('detects a runnable copy behind an executable shim that exits 126', async () => {
|
||||
const shim = await cliInDirectory('shim', BROKEN_SHIM)
|
||||
const good = await cliInDirectory('good')
|
||||
vi.stubEnv('PATH', [shim, good].join(path.delimiter))
|
||||
|
||||
await expect(isCommandOnPath(COMMAND)).resolves.toBe(true)
|
||||
await expect(
|
||||
execLocalPreflightCommandOrThrow(path.join(shim, COMMAND), ['--version'])
|
||||
).rejects.toMatchObject({ code: 126, stderr: 'broken-shim\n' })
|
||||
await expect(
|
||||
execLocalPreflightCommandOrThrow(path.join(good, COMMAND), ['--version'])
|
||||
).resolves.toMatchObject({ stdout: 'gh-version-fixture\n' })
|
||||
await expect(isCommandAvailable(COMMAND)).resolves.toBe(true)
|
||||
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: path.join(good, COMMAND)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps PATH order and leaves later copies unprobed when the first works', async () => {
|
||||
const first = await cliInDirectory('first')
|
||||
const second = await cliInDirectory('second')
|
||||
vi.stubEnv('PATH', [first, second].join(path.delimiter))
|
||||
|
||||
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: path.join(first, COMMAND)
|
||||
})
|
||||
expect(await probedArgs(first)).toEqual(['--version'])
|
||||
expect(await probedArgs(second)).toEqual([])
|
||||
})
|
||||
|
||||
it('distinguishes exhausted failing copies from an absent command', async () => {
|
||||
const first = await cliInDirectory('first', BROKEN_SHIM)
|
||||
const second = await cliInDirectory('second', BROKEN_SHIM)
|
||||
vi.stubEnv('PATH', [first, second].join(path.delimiter))
|
||||
|
||||
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
|
||||
status: 'exec_failed',
|
||||
binary: path.join(second, COMMAND)
|
||||
})
|
||||
await expect(findRunnableLocalCommand('orca-22975-absent-cli')).resolves.toEqual({
|
||||
status: 'absent'
|
||||
})
|
||||
expect(await probedArgs(first)).toEqual(['--version'])
|
||||
expect(await probedArgs(second)).toEqual(['--version'])
|
||||
})
|
||||
|
||||
it('skips directories and non-executable files before a working copy', async () => {
|
||||
const directory = path.join(root, 'directory')
|
||||
await mkdir(path.join(directory, COMMAND), { recursive: true })
|
||||
const plain = await cliInDirectory('plain')
|
||||
await chmod(path.join(plain, COMMAND), 0o644)
|
||||
const good = await cliInDirectory('good')
|
||||
vi.stubEnv('PATH', [directory, plain, good].join(path.delimiter))
|
||||
|
||||
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: path.join(good, COMMAND)
|
||||
})
|
||||
expect(await probedArgs(plain)).toEqual([])
|
||||
})
|
||||
|
||||
it('never substitutes another binary for an explicit failing path', async () => {
|
||||
const shim = await cliInDirectory('shim', BROKEN_SHIM)
|
||||
const good = await cliInDirectory('good')
|
||||
vi.stubEnv('PATH', good)
|
||||
const selected = path.join(shim, COMMAND)
|
||||
|
||||
await expect(findRunnableLocalCommand(selected)).resolves.toEqual({
|
||||
status: 'exec_failed',
|
||||
binary: selected
|
||||
})
|
||||
expect(await probedArgs(good)).toEqual([])
|
||||
})
|
||||
|
||||
it('executes relative explicit paths and reports an explicit missing path', async () => {
|
||||
const good = await cliInDirectory('good')
|
||||
const selected = path.relative(process.cwd(), path.join(good, COMMAND))
|
||||
vi.stubEnv('PATH', '')
|
||||
|
||||
await expect(findRunnableLocalCommand(selected)).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: selected
|
||||
})
|
||||
await expect(findRunnableLocalCommand(path.join(root, 'missing', COMMAND))).resolves.toEqual({
|
||||
status: 'absent'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'preserves relative PATH order with shim first: %s',
|
||||
async (shimFirst) => {
|
||||
const good = await cliInDirectory('good')
|
||||
const shim = await cliInDirectory('shim', BROKEN_SHIM)
|
||||
const relative = path.relative(process.cwd(), good)
|
||||
vi.stubEnv('PATH', (shimFirst ? [shim, relative] : [relative, shim]).join(path.delimiter))
|
||||
|
||||
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: path.join(good, COMMAND)
|
||||
})
|
||||
expect(await probedArgs(good)).toEqual(['--version'])
|
||||
expect(await probedArgs(shim)).toEqual(shimFirst ? ['--version'] : [])
|
||||
}
|
||||
)
|
||||
|
||||
it('deduplicates PATH entries before spending the probe limit', async () => {
|
||||
const shim = await cliInDirectory('shim', BROKEN_SHIM)
|
||||
const good = await cliInDirectory('good')
|
||||
vi.stubEnv('PATH', [shim, shim, shim, shim, good].join(path.delimiter))
|
||||
|
||||
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: path.join(good, COMMAND)
|
||||
})
|
||||
expect(await probedArgs(shim)).toEqual(['--version'])
|
||||
})
|
||||
|
||||
it('caps execution at four distinct candidates and reports the limit', async () => {
|
||||
const doomed: string[] = []
|
||||
for (const index of [0, 1, 2, 3]) {
|
||||
doomed.push(await cliInDirectory(`doomed-${index}`, BROKEN_SHIM))
|
||||
}
|
||||
const good = await cliInDirectory('past-cap')
|
||||
vi.stubEnv('PATH', [...doomed, good].join(path.delimiter))
|
||||
|
||||
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
|
||||
status: 'limit_reached',
|
||||
binary: path.join(doomed[3], COMMAND)
|
||||
})
|
||||
for (const dir of doomed) {
|
||||
expect(await probedArgs(dir)).toEqual(['--version'])
|
||||
}
|
||||
expect(await probedArgs(good)).toEqual([])
|
||||
})
|
||||
|
||||
it('stops at a real timeout and leaves later copies unprobed', async () => {
|
||||
const hung = await cliInDirectory('hung', 'exec /bin/sleep 30\n')
|
||||
const good = await cliInDirectory('good')
|
||||
vi.stubEnv('PATH', [hung, good].join(path.delimiter))
|
||||
|
||||
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
|
||||
status: 'timeout',
|
||||
binary: path.join(hung, COMMAND)
|
||||
})
|
||||
expect(await probedArgs(good)).toEqual([])
|
||||
}, 10_000)
|
||||
|
||||
it('uses the known Nix install directory after PATH copies fail', async () => {
|
||||
const shim = await cliInDirectory('shim', BROKEN_SHIM)
|
||||
const profile = await cliInDirectory(path.join('.nix-profile', 'bin'))
|
||||
vi.stubEnv('PATH', shim)
|
||||
|
||||
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
|
||||
status: 'available',
|
||||
binary: path.join(profile, COMMAND)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
describe.runIf(process.platform === 'win32')('Windows preflight batch shims', () => {
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-cmd-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await removeTree(root)
|
||||
})
|
||||
|
||||
it('runs the next cmd shim after an earlier copy exits 126', async () => {
|
||||
const first = path.join(root, 'first')
|
||||
const second = path.join(root, 'second')
|
||||
for (const dir of [first, second]) {
|
||||
await mkdir(dir)
|
||||
}
|
||||
const name = `${COMMAND}.CMD`
|
||||
await writeFile(path.join(first, name), '@echo off\r\nexit /b 126\r\n')
|
||||
await writeFile(
|
||||
path.join(second, name),
|
||||
'@echo off\r\necho gh-version-fixture\r\nexit /b 0\r\n'
|
||||
)
|
||||
vi.stubEnv('PATH', [first, second].join(path.delimiter))
|
||||
vi.stubEnv('Path', [first, second].join(path.delimiter))
|
||||
|
||||
const result = await findRunnableLocalCommand(COMMAND)
|
||||
|
||||
expect(result).toEqual({ status: 'available', binary: path.posix.join(second, name) })
|
||||
if (result.status === 'available') {
|
||||
await expect(
|
||||
execLocalPreflightCommandOrThrow(result.binary, ['auth', 'status'])
|
||||
).resolves.toMatchObject({ stdout: expect.stringContaining('gh-version-fixture') })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ export type PreflightMocks = {
|
||||
getGiteaAuthStatusMock: Mock
|
||||
resolveCliCommandsMock: Mock
|
||||
isCommandOnLocalPathMock: Mock
|
||||
listLocalCommandPathsMock: Mock
|
||||
mergePersistedWindowsPathAsyncMock: Mock
|
||||
mergePersistedWindowsPathMock: Mock
|
||||
}
|
||||
@@ -52,6 +53,7 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap)
|
||||
getGiteaAuthStatusMock,
|
||||
resolveCliCommandsMock,
|
||||
isCommandOnLocalPathMock,
|
||||
listLocalCommandPathsMock,
|
||||
mergePersistedWindowsPathAsyncMock,
|
||||
mergePersistedWindowsPathMock
|
||||
} = mocks
|
||||
@@ -63,6 +65,11 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap)
|
||||
hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' })
|
||||
mergePathSegmentsMock.mockReset()
|
||||
getActiveMultiplexerMock.mockReset()
|
||||
// Why empty by default: with no fs candidates the local probe keeps its
|
||||
// historical bare-name spawn, so cases that stub only where/which and execFile
|
||||
// still assert on the command names they were written against.
|
||||
listLocalCommandPathsMock.mockReset()
|
||||
listLocalCommandPathsMock.mockResolvedValue([])
|
||||
getBitbucketAuthStatusMock.mockReset()
|
||||
getAzureDevOpsAuthStatusMock.mockReset()
|
||||
getGiteaAuthStatusMock.mockReset()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({
|
||||
@@ -46,18 +47,7 @@ vi.mock('node-pty', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../opencode/hook-service', () => ({
|
||||
openCodeHookService: {
|
||||
buildPtyEnv: () => ({}),
|
||||
refreshLegacySharedPlugin: vi.fn(),
|
||||
clearPty: vi.fn()
|
||||
},
|
||||
openCode2HookService: {
|
||||
buildPtyEnv: () => ({}),
|
||||
refreshLegacySharedPlugin: vi.fn(),
|
||||
clearPty: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock())
|
||||
|
||||
vi.mock('../pi/titlebar-extension-service', () => ({
|
||||
piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() }
|
||||
|
||||
@@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record<string, unknown>) => ({
|
||||
})
|
||||
|
||||
export const openCodeHookServiceModuleMock = () => ({
|
||||
OpenCodeHookService: class {
|
||||
buildPtyEnv = vi.fn(() => ({}))
|
||||
},
|
||||
openCodeHookService: {
|
||||
buildPtyEnv: openCodeBuildPtyEnvMock,
|
||||
refreshLegacySharedPlugin: vi.fn<() => void>(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
|
||||
import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty'
|
||||
import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { selectShellStartupFeatures } from '../shell-startup-features'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
@@ -60,6 +61,41 @@ describe('registerPtyHandlers', () => {
|
||||
const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite()
|
||||
|
||||
describe('spawn environment', () => {
|
||||
it.each(['/bin/bash', '/bin/zsh'])(
|
||||
'does not wrap a bare %s pane merely to expose this app CLI',
|
||||
(shellPath) => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
try {
|
||||
const env = buildPtyHostEnv(
|
||||
'bare-cli-pane',
|
||||
{},
|
||||
{
|
||||
isPackaged: false,
|
||||
userDataPath: '/tmp/orca-user-data',
|
||||
selectedCodexHomePath: null,
|
||||
agentStatusHooksEnabled: false
|
||||
}
|
||||
)
|
||||
expect(env.ORCA_CLI_BIN_DIR).toBe('/tmp/orca-user-data/cli/bin')
|
||||
expect(
|
||||
selectShellStartupFeatures({
|
||||
shellPath,
|
||||
env,
|
||||
hasStartupCommand: false,
|
||||
waitsForShellReady: false,
|
||||
emitsStartupIdentity: false
|
||||
})
|
||||
).toEqual([])
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('does not install managed Pi extensions when Pi is disabled', () => {
|
||||
piBuildPtyEnvMock.mockClear()
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
|
||||
import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
|
||||
import {
|
||||
detectExplicitPiAgentKindFromCommand,
|
||||
isPiCompatibleAgentType
|
||||
} from '../../../../shared/pi-agent-kind'
|
||||
import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard'
|
||||
import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service'
|
||||
import {
|
||||
OPENCODE_CONFIG_DIR_ENV_KEYS,
|
||||
isOpenCodeLegacySharedConfigDir
|
||||
} from '../../../opencode/legacy-shared-config-dir'
|
||||
import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer'
|
||||
import { mimoCodeHookService } from '../../../mimo/hook-service'
|
||||
import { agentHookServer } from '../../../agent-hooks/server'
|
||||
import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager'
|
||||
@@ -28,13 +23,13 @@ import {
|
||||
exposePiManagedExtensionEnv,
|
||||
isMimoLaunchCommand,
|
||||
resolveMimocodeSourceHome,
|
||||
resolveOpenCodeSourceConfigDir,
|
||||
resolvePiAgentSourceDir,
|
||||
resolveScopedPiAgentSourceDir,
|
||||
restoreOrStripOverlayEnv
|
||||
} from './pi-agent'
|
||||
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys'
|
||||
import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment'
|
||||
import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config'
|
||||
|
||||
/**
|
||||
* Mutates `baseEnv` in place with all host-local PTY env vars and returns it.
|
||||
@@ -50,32 +45,9 @@ export function buildPtyHostEnv(
|
||||
mergePersistedWindowsPath(baseEnv)
|
||||
Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings))
|
||||
|
||||
// Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config.
|
||||
const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean =>
|
||||
isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath)
|
||||
const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {}
|
||||
for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) {
|
||||
if (isLegacyOpenCodeHooksDir(baseEnv[key])) {
|
||||
delete baseEnv[key]
|
||||
}
|
||||
if (!isLegacyOpenCodeHooksDir(process.env[key])) {
|
||||
inheritedOpenCodeEnv[key] = process.env[key]
|
||||
}
|
||||
}
|
||||
// A daemon or sibling shell can retain a retired path that main no longer sees.
|
||||
openCodeHookService.refreshLegacySharedPlugin()
|
||||
openCode2HookService.refreshLegacySharedPlugin()
|
||||
const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv)
|
||||
const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir)
|
||||
? undefined
|
||||
: resolvedOpenCodeConfigDir
|
||||
const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath)
|
||||
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
|
||||
applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint })
|
||||
const openCodeAgent = selectOpenCodeHookAgent(
|
||||
opts.launchAgent,
|
||||
launchCommandHint,
|
||||
(agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents)
|
||||
)
|
||||
const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent)
|
||||
? opts.launchAgent
|
||||
: opts.launchAgent === undefined
|
||||
@@ -110,37 +82,13 @@ export function buildPtyHostEnv(
|
||||
? resolvePiAgentSourceDir(baseEnv, 'prime-agent')
|
||||
: resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent')
|
||||
|
||||
restoreOrStripOverlayEnv(
|
||||
const openCodeAgent = applyOpenCodeStatusPluginEnv(
|
||||
id,
|
||||
baseEnv,
|
||||
{
|
||||
primary: 'OPENCODE_CONFIG_DIR',
|
||||
overlay: 'ORCA_OPENCODE_CONFIG_DIR',
|
||||
source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR',
|
||||
preserveExplicitPrimary: true
|
||||
},
|
||||
inheritedOpenCodeEnv
|
||||
openCodeConfig,
|
||||
opts,
|
||||
launchCommandHint
|
||||
)
|
||||
delete baseEnv.ORCA_OPENCODE_AGENT
|
||||
if (openCodeAgent) {
|
||||
// Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md.
|
||||
const openCodeStatusService =
|
||||
openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService
|
||||
baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent
|
||||
// WSL owns its config writes; only the guest overlay may enter a WSL pane.
|
||||
if (!opts.isWsl) {
|
||||
Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
|
||||
}
|
||||
if (baseEnv.OPENCODE_CONFIG_DIR) {
|
||||
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value.
|
||||
baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR
|
||||
if (preexistingOpenCodeConfigDir) {
|
||||
// Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays.
|
||||
baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir
|
||||
} else {
|
||||
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opts.agentStatusHooksEnabled) {
|
||||
if (isMimoLaunchCommand(launchCommandHint)) {
|
||||
const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv)
|
||||
@@ -343,5 +291,8 @@ export function buildPtyHostEnv(
|
||||
// process.env when baseEnv carries none, which is the daemon path's normal shape.
|
||||
stripLegacyTerminalShimEnv(baseEnv, process.platform)
|
||||
|
||||
if (!opts.isWsl) {
|
||||
ensureOpenCodeStartupPromptForLaunch(baseEnv)
|
||||
}
|
||||
return baseEnv
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user