Files
orca/src/main/hooks-runner.test.ts
T
8c5371ebad fix(worktrees): respect Windows shell for setup runners (#6967)
* Honor configured shells during worktree setup

* Align setup launch paths with selected Windows shells

* Carry setup shell selection through deferred launches

* Prove Windows setup shell routing at its real adapters

* Ground remote PowerShell proof in the real writer

* Preserve Git Bash across deferred setup launches

* Harden Windows setup runner shell selection

- Resolve remote PowerShell binary without local pwsh probe: for SSH/remote
  Windows worktrees, isPwshAvailable() reflects only the LOCAL host, so an
  'auto' implementation could route the remote runner to a pwsh.exe the remote
  lacks. Add resolveSetupRunnerShell(..., { probeLocalPwsh: false }) so remote
  auto keeps the always-present powershell.exe; explicit pwsh.exe still honored.
- Preserve native exit codes in the PowerShell runner by checking
  $LASTEXITCODE before $?, so a failing native command surfaces its real code
  instead of a generic exit 1; $? still catches cmdlet soft-failures.
- Write the PowerShell runner with a UTF-8 BOM so Windows PowerShell 5.1 (the
  new default powershell.exe) reads it as UTF-8 instead of ANSI, preventing
  non-ASCII setup-script corruption.
- Add unit tests for the remote-probe behavior.

* Restore setup-shell scope narrowing over the rebase

The force-pushed rebase dropped five review-fix commits that were already
on this branch; this reapplies their combined effect on top of the new
base and the hardening commit:

- Keep SSH setup shell selection remote-owned (no local terminalWindowsShell
  or pwsh routing for remote hosts; supersedes the probeLocalPwsh guard)
- Preserve cmd setup compatibility outside POSIX shells (no .ps1 runner
  family, so the BOM/exit-code hardening is no longer applicable)
- Route WSL setup runners from the project runtime
- Avoid blocking PowerShell probes during setup creation
- Correct SSH and WSL background setup fixtures

* Satisfy the changed-code gates for the setup-shell runner

- createWorktreeRunnerScript took 7 positional parameters, tripping the
  changed-code max-params gate; move it to a single options object.
- hooks-runner.test.ts deep-equals the createSetupRunnerScript result, so
  assert the cmd shell now returned for native Windows worktrees.

* Carry the setup launch shell through observed and issue runners

- buildObservedSetupCommand takes the runner's launch shell so WSL-routed
  Windows-drive setup replays use /mnt/c instead of Git Bash /c
- resolveSetupRunnerShell gates the posix runner on the same Git Bash
  resolution the PTY uses, so a missing or non-MSYS bash keeps the cmd runner
- issue-command runners carry their launch shell, and the renderer passes it
  when building the queued command
- treat a bare `bash` shell setting as POSIX like `bash.exe`

Co-authored-by: Orca <help@stably.ai>

* fix(worktrees): close counsel P1 gaps for Windows setup shells

Route windowless/headless creates through the shell-aware setup runner when a
PTY controller is available, existence-check explicit Git Bash paths before
committing to .sh runners, thread the resolved shell into issue-command
runners, and document the intentional Git Bash interpreter flip with a narrow
scope table.

* Convert setup env to MSYS form and harden the bare cmd runner launch

C3: a Git Bash setup runner now receives ORCA_*/CONDUCTOR_*/GHOSTX_* path
values in /c/... form, matching the runner path and the shell's own HOME/PWD.
C5: extension-less `bash` resolves to Git Bash everywhere, matching how
resolveWindowsShellStartupFamily already classifies it.
C7: runner paths carrying characters that cannot be quoted on a cmd command
line launch through a delayed-expansion PowerShell shim instead, and the batch
runner disables inherited delayed expansion so `!` in setup lines survives.

Co-authored-by: Orca <help@stably.ai>

* docs: note MSYS ORCA_* paths and bare bash Git Bash resolution

Keep the setup-shell release note aligned with C3 env conversion and C5 bare
bash resolution so the published claim matches runtime behavior.

* revert: drop windows-setup-shell doc allowlist and AGENTS link

Keep the counsel P1/P2 product fixes without expanding the docs allowlist
or AGENTS.md guidance surface.

* fix(plugins): contain Parcel unsubscribe rejections under Vitest

Dev plugin watchers fire-and-forget unsubscribe, and in-process Parcel
can reject when temp watch roots are already deleted. Catch those
rejections so they cannot fail the suite as unhandled errors.

* fix(plugins): keep in-process unsubscribe rejection surface

Swallowing Parcel unsubscribe errors broke mocked unsubscribe tests
that return non-Promises and expect rejections. Contain failures only
in PluginDevWatcher fire-and-forget paths.

---------

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
2026-08-02 17:40:58 -07:00

417 lines
14 KiB
TypeScript

import type { Repo } from '../shared/types'
import { describe, expect, it, vi } from 'vitest'
const { execFileSyncMock } = vi.hoisted(() => ({
execFileSyncMock: vi.fn()
}))
vi.mock('fs', () => ({
readFileSync: vi.fn(),
existsSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
chmodSync: vi.fn()
}))
vi.mock('child_process', () => ({
exec: vi.fn(),
execFileSync: execFileSyncMock,
// runner.ts imports these from child_process; stubs prevent
// "missing export" errors when the mock is resolved transitively.
execFile: vi.fn(),
spawn: vi.fn()
}))
describe('createSetupRunnerScript', () => {
const makeRepo = () =>
({
id: 'test-id',
path: '/test/repo',
displayName: 'Test Repo',
badgeColor: '#000',
addedAt: Date.now()
}) as unknown as Repo
it('writes a fail-fast Windows runner that returns after batch commands', async () => {
const fs = await import('node:fs')
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
const { createSetupRunnerScript } = await import('./hooks')
const result = createSetupRunnerScript(
makeRepo(),
'C:\\repo\\feature\\',
'pnpm install\npnpm build'
)
expect(result).toEqual({
runnerScriptPath: 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd',
envVars: expect.objectContaining({
ORCA_ROOT_PATH: '/test/repo',
ORCA_WORKTREE_PATH: 'C:\\repo\\feature\\',
ORCA_WORKSPACE_NAME: 'feature'
}),
// Why: native Windows worktrees without a configured setup shell keep the cmd runner.
shell: { family: 'cmd' }
})
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd',
[
'@echo off',
'setlocal EnableExtensions DisableDelayedExpansion',
'call pnpm install',
'if errorlevel 1 exit /b %errorlevel%',
'call pnpm build',
'if errorlevel 1 exit /b %errorlevel%',
''
].join('\r\n'),
'utf-8'
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('converts path env vars to MSYS form for a native Windows Git Bash runner', async () => {
const fs = await import('node:fs')
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const { createSetupRunnerScript } = await import('./hooks')
const result = createSetupRunnerScript(
{ ...makeRepo(), path: 'C:\\Users\\jinwo\\git\\orca' },
'C:\\repo\\feature',
'pnpm install',
undefined,
{ family: 'posix' }
)
expect(result).toEqual({
runnerScriptPath: 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
envVars: expect.objectContaining({
ORCA_ROOT_PATH: '/c/Users/jinwo/git/orca',
ORCA_WORKTREE_PATH: '/c/repo/feature',
CONDUCTOR_ROOT_PATH: '/c/Users/jinwo/git/orca',
GHOSTX_ROOT_PATH: '/c/Users/jinwo/git/orca',
// Why: a display name, never a path — it must survive the conversion untouched.
ORCA_WORKSPACE_NAME: 'feature'
}),
shell: { family: 'posix' }
})
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
'#!/usr/bin/env bash\nset -e\npnpm install\n',
'utf-8'
)
// Why: chmod over a native Windows path is meaningless; only the WSL branch sets the bit.
expect(vi.mocked(fs.chmodSync)).not.toHaveBeenCalledWith(
'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
0o755
)
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('leaves non-path setup env values alone under a Git Bash runner', async () => {
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const { createSetupRunnerScript } = await import('./hooks')
const { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } =
await import('../shared/terminal-git-credential-guard')
const result = createSetupRunnerScript(
makeRepo(),
'C:\\repo\\feature',
'pnpm install',
undefined,
{ family: 'posix' }
)
expect(result.envVars[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBe('guard')
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('keeps native Windows env vars in Windows form for the default cmd runner', async () => {
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const { createSetupRunnerScript } = await import('./hooks')
const result = createSetupRunnerScript(
{ ...makeRepo(), path: 'C:\\Users\\jinwo\\git\\orca' },
'C:\\repo\\feature',
'pnpm install'
)
expect(result.envVars).toEqual(
expect.objectContaining({
ORCA_ROOT_PATH: 'C:\\Users\\jinwo\\git\\orca',
ORCA_WORKTREE_PATH: 'C:\\repo\\feature'
})
)
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('preserves exclamation marks in Windows runner script lines', async () => {
const { buildWindowsRunnerScript } = await import('./hooks')
const runner = buildWindowsRunnerScript('echo hello!world!')
// Why: launchers invoke the runner under cmd /v:on, so the runner must disable delayed
// expansion itself or `!world!` is consumed as a variable reference.
expect(runner).toContain('setlocal EnableExtensions DisableDelayedExpansion')
expect(runner).toContain('call echo hello!world!')
})
it('derives ORCA_WORKSPACE_NAME from a POSIX worktree path', async () => {
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue('/test/repo/.git/worktrees/feature/orca/setup-runner.sh')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux'
})
try {
const { createSetupRunnerScript } = await import('./hooks')
const result = createSetupRunnerScript(makeRepo(), '/test/repo-feature', 'pnpm install')
expect(result.envVars).toEqual(
expect.objectContaining({
ORCA_WORKTREE_PATH: '/test/repo-feature',
ORCA_WORKSPACE_NAME: 'repo-feature'
})
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('translates WSL runner paths and env vars to Linux form on Windows', async () => {
const fs = await import('node:fs')
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue('/home/jin/.git/worktrees/feature/orca/setup-runner.sh')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
const { createSetupRunnerScript } = await import('./hooks')
const result = createSetupRunnerScript(
{
...makeRepo(),
path: 'C:\\Users\\jinwo\\git\\orca'
},
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\feature',
'pnpm install'
)
expect(result).toEqual({
runnerScriptPath:
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
envVars: expect.objectContaining({
ORCA_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca',
ORCA_WORKTREE_PATH: '/home/jin/feature',
ORCA_WORKSPACE_NAME: 'feature',
CONDUCTOR_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca',
GHOSTX_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca'
})
})
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
'#!/usr/bin/env bash\nset -e\npnpm install\n',
'utf-8'
)
expect(vi.mocked(fs.chmodSync)).toHaveBeenCalledWith(
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
0o755
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('translates WSL env vars to Linux paths when the worktree lives on a WSL UNC path', async () => {
const fs = await import('node:fs')
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue('/home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
const { createSetupRunnerScript } = await import('./hooks')
const result = createSetupRunnerScript(
makeRepo(),
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\feature',
'pnpm install'
)
expect(result).toEqual({
runnerScriptPath:
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
envVars: expect.objectContaining({
ORCA_ROOT_PATH: '/test/repo',
ORCA_WORKTREE_PATH: '/home/jin/repo/feature',
ORCA_WORKSPACE_NAME: 'feature',
CONDUCTOR_ROOT_PATH: '/test/repo',
GHOSTX_ROOT_PATH: '/test/repo'
})
})
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
'#!/usr/bin/env bash\nset -e\npnpm install\n',
'utf-8'
)
expect(vi.mocked(fs.chmodSync)).toHaveBeenCalledWith(
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
0o755
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
})
describe('createIssueCommandRunnerScript', () => {
const makeRepo = () =>
({
id: 'test-id',
path: '/test/repo',
displayName: 'Test Repo',
badgeColor: '#000',
addedAt: Date.now()
}) as unknown as Repo
it('writes a POSIX runner under the worktree git dir for long issue commands', async () => {
const fs = await import('node:fs')
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue(
'/test/repo/.git/worktrees/feature/orca/issue-command-runner.sh'
)
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux'
})
try {
const { createIssueCommandRunnerScript } = await import('./hooks')
const result = createIssueCommandRunnerScript(
makeRepo(),
'/test/repo-feature',
'codex exec "long command"\nclaude -p "review it"'
)
expect(result).toEqual({
runnerScriptPath: '/test/repo/.git/worktrees/feature/orca/issue-command-runner.sh',
envVars: expect.objectContaining({
ORCA_ROOT_PATH: '/test/repo',
ORCA_WORKTREE_PATH: '/test/repo-feature'
})
})
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
'/test/repo/.git/worktrees/feature/orca/issue-command-runner.sh',
'#!/usr/bin/env bash\nset -e\ncodex exec "long command"\nclaude -p "review it"\n',
'utf-8'
)
expect(vi.mocked(fs.chmodSync)).toHaveBeenCalledWith(
'/test/repo/.git/worktrees/feature/orca/issue-command-runner.sh',
0o755
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('carries the WSL launch shell for a Windows-drive worktree routed through WSL', async () => {
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue('/mnt/c/repo/.git/orca/issue-command-runner.sh')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
const { createIssueCommandRunnerScript } = await import('./hooks')
const result = createIssueCommandRunnerScript(
makeRepo(),
'C:\\repo\\feature',
'codex exec "long command"',
{ wslDistro: 'Ubuntu' }
)
// Why: the runner path is written back in native Windows form, so the launch needs /mnt again.
expect(result.runnerScriptPath).toBe('C:\\repo\\.git\\orca\\issue-command-runner.sh')
expect(result.shell).toEqual({ family: 'posix', executable: 'wsl.exe' })
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('keeps native Windows issue runners on the cmd launch shell', async () => {
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.cmd')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
const { createIssueCommandRunnerScript } = await import('./hooks')
const result = createIssueCommandRunnerScript(makeRepo(), 'C:\\repo\\feature', 'pnpm install')
expect(result.shell).toEqual({ family: 'cmd' })
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
})