Files
orca/src/shared/fish-binary-requirement.test.ts
T
NeilandOrca 5ea7df1a5b fix(terminal): make DECSET 2031 subscriptions silent (#13904)
fish arms `CSI ?2031h` before painting each prompt and withdraws it when it
hands the tty to a child — a ~1ms window. Orca answered that subscribe with
`CSI ?997;Nn` across a 1-3ms renderer hop, so the reply landed after the
withdrawal and was read as stdin by the next child, corrupting `brew`/`npx`
`[y/N]` prompts.

The reply is not stale by Orca's own view when written (measured
staleReplies: 0), so no suppress-the-stale-reply scheme can close this — the
information needed to suppress does not exist yet. Nothing asked for the reply
either. The Contour spec says a terminal "should only send out the DSR when the
palette has been updated"; Ghostty (Termio.zig:729 — force=true reachable only
from the ?996n DSR), iTerm2 (VT100Terminal.m:995 — flag only) and xterm.js
(InputHandler.ts:2035 — flag only) all emit nothing on the DECSET. So stop
entering the race: record the subscription, answer nothing.

Of 17 real programs measured under a pty, only fish, tmux, claude and opencode
subscribe; none block on a reply, and answering produces one redundant palette
re-query and zero rendering difference. tmux is the only one that sends `?996n`,
which Orca still answers.

- Subscribes are record-only at all four emitters (live scan, hidden-gate fact,
  parked byte watcher, parked responder — the last is deleted, it only replied).
- `?996n` answers, the subscription registry, and the theme-flip push are
  unchanged. `paneLastThemeMode` is still seeded at subscribe so the next
  appearance re-apply is not read as a flip.
- Replay grammar carries `?2031l` alongside `?2031h`, so a late-attaching remote
  client no longer registers a subscription the TUI already retired.

Also closes fish-integration gaps found alongside: `unset` (which fish lacks)
becomes `set -e` on paths parsed by the client's login shell, `config.fish` is
parsed for agent-home detection, and bracketed-paste startup delivery is made
consistent across local/daemon/relay.

Regression test drives real fish 4.7.1 under node-pty and asserts on what the
child process reads; it fails against pre-fix code with the exact payload from
the issue. CI installs fish 4 and fails loudly rather than skipping.

Closes #9993

Co-authored-by: Orca <help@stably.ai>
2026-08-11 21:16:36 -07:00

55 lines
1.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
fishRequirementViolation,
REQUIRE_FISH_ENV_VAR,
resolveFishBinary,
type FishBinaryLookup
} from './fish-binary-requirement'
const FOUND: FishBinaryLookup = { available: true, path: '/usr/bin/fish', majorVersion: 4 }
const MISSING: FishBinaryLookup = {
available: false,
path: null,
majorVersion: 0,
reason: 'no fish binary on PATH'
}
describe('fishRequirementViolation', () => {
it('lets a missing fish skip when the requirement is not set', () => {
expect(fishRequirementViolation(MISSING, {})).toBeNull()
expect(fishRequirementViolation(MISSING, { [REQUIRE_FISH_ENV_VAR]: '0' })).toBeNull()
})
// The #9993 guard is CI's only end-to-end coverage; a skip there is a silent gap.
it('reports a violation naming the reason when CI requires fish and it is absent', () => {
const violation = fishRequirementViolation(MISSING, { [REQUIRE_FISH_ENV_VAR]: '1' })
expect(violation).toContain(REQUIRE_FISH_ENV_VAR)
expect(violation).toContain('no fish binary on PATH')
})
it('reports no violation when fish is present, requirement set or not', () => {
expect(fishRequirementViolation(FOUND, { [REQUIRE_FISH_ENV_VAR]: '1' })).toBeNull()
expect(fishRequirementViolation(FOUND, {})).toBeNull()
})
})
describe('resolveFishBinary', () => {
it('treats an installed fish below the floor as unavailable, with the version in the reason', () => {
const lookup = resolveFishBinary(Number.MAX_SAFE_INTEGER)
if (lookup.available) {
throw new Error('no fish can satisfy an unreachable version floor')
}
// Only when a fish is actually installed does the reason describe a version.
if (lookup.majorVersion > 0) {
expect(lookup.reason).toContain(`fish ${Number.MAX_SAFE_INTEGER}+ required`)
}
})
it('never reports a path on Windows', () => {
if (process.platform !== 'win32') {
return
}
expect(resolveFishBinary().path).toBeNull()
})
})