mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
test: remove 872 low-value PTY and shell tests (#38943)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -861,8 +861,6 @@ jobs:
|
||||
src/main/pty/shell-startup/omp-shell-wrapper-alias-safety.test.ts \
|
||||
src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/main/pty/shell-startup/fish-xdg-data-dirs-handoff.test.ts \
|
||||
src/main/pty/shell-startup/shell-startup-feature-channel.test.ts \
|
||||
src/main/pty/shell-startup/zsh-deferred-startup-line-init.live-shell.test.ts \
|
||||
src/main/pty/history/terminal-history-fish-session.node-pty.test.ts \
|
||||
src/main/pty/shell-startup/zsh-scoped-histfile.live-shell.test.ts \
|
||||
src/main/pty/shell-startup/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
1 test-no-in-test-import-actual:src/main/linear/linear-workspace-registry.test.ts
|
||||
1 test-no-in-test-import-actual:src/main/omp/omp-session-cwd-lookup.test.ts
|
||||
1 test-no-in-test-import-actual:src/main/orcad/electron-serve-provider-selection.test.ts
|
||||
1 test-no-in-test-import-actual:src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts
|
||||
1 test-no-in-test-import-actual:src/main/pty/scrollback/overlay-mirror.test.ts
|
||||
1 test-no-in-test-import-actual:src/main/pty/shell-startup/legacy-terminal-shim-dir.test.ts
|
||||
1 test-no-in-test-import-actual:src/main/rate-limits/claude-profile-usage.test.ts
|
||||
1 test-no-in-test-import-actual:src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts
|
||||
1 test-no-in-test-import-actual:src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts
|
||||
1 test-no-in-test-import-actual:src/main/runtime/rpc/server/pairing/mobile-pairing-userdata-path.test.ts
|
||||
@@ -71,7 +68,6 @@
|
||||
1 test-no-polling-loop:src/main/provider-process/provider-process-supervisor.integration.test.ts
|
||||
1 test-no-polling-loop:src/main/providers/startup-line-typed-length.live-shell.test.ts
|
||||
1 test-no-polling-loop:src/main/pty/history/terminal-history-fish-session.node-pty.test.ts
|
||||
1 test-no-polling-loop:src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts
|
||||
1 test-no-polling-loop:src/main/pty/process-retirement/posix-pty-process-groups.integration.test.ts
|
||||
1 test-no-polling-loop:src/main/pty/process-retirement/windows-live-tree-kill.win32.test.ts
|
||||
1 test-no-polling-loop:src/main/runtime/agent-session/environment/structured-agent-shell-environment.test.ts
|
||||
@@ -264,11 +260,10 @@
|
||||
6 test-no-real-time-wait:src/main/opencode/hook-plugin-module-contract.test.ts
|
||||
4 test-no-real-time-wait:src/main/orcad/orcad-stop-request-listener.test.ts
|
||||
1 test-no-real-time-wait:src/main/provider-process/provider-group-orphan-guard.integration.test.ts
|
||||
3 test-no-real-time-wait:src/main/provider-process/provider-process-supervisor.integration.test.ts
|
||||
2 test-no-real-time-wait:src/main/provider-process/provider-process-supervisor.integration.test.ts
|
||||
1 test-no-real-time-wait:src/main/providers/agent-foreground-process-git-bash.win32.test.ts
|
||||
2 test-no-real-time-wait:src/main/providers/startup-line-typed-length.live-shell.test.ts
|
||||
2 test-no-real-time-wait:src/main/pty/history/terminal-history-fish-session.node-pty.test.ts
|
||||
2 test-no-real-time-wait:src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts
|
||||
1 test-no-real-time-wait:src/main/pty/process-retirement/node-pty-master-fd-retirement.test.ts
|
||||
1 test-no-real-time-wait:src/main/pty/process-retirement/posix-pty-process-groups.integration.test.ts
|
||||
5 test-no-real-time-wait:src/main/pty/process-retirement/windows-live-tree-kill.win32.test.ts
|
||||
@@ -673,18 +668,13 @@
|
||||
2 test-no-wall-clock:src/main/persistence/tracking-repos/persistence-deregistered-repo-residue.test.ts
|
||||
1 test-no-wall-clock:src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts
|
||||
2 test-no-wall-clock:src/main/provider-process/provider-group-orphan-guard.integration.test.ts
|
||||
1 test-no-wall-clock:src/main/provider-process/provider-group-orphan-guard.test.ts
|
||||
18 test-no-wall-clock:src/main/provider-process/provider-process-supervisor.integration.test.ts
|
||||
3 test-no-wall-clock:src/main/provider-process/provider-process-teardown.test.ts
|
||||
4 test-no-wall-clock:src/main/provider-process/provider-process-supervisor.integration.test.ts
|
||||
2 test-no-wall-clock:src/main/providers/startup-line-typed-length.live-shell.test.ts
|
||||
1 test-no-wall-clock:src/main/pty/history/fish-history-session.test.ts
|
||||
2 test-no-wall-clock:src/main/pty/history/terminal-history-fish-session.node-pty.test.ts
|
||||
1 test-no-wall-clock:src/main/pty/history/terminal-history-gc-fs-call-count.test.ts
|
||||
2 test-no-wall-clock:src/main/pty/process-retirement/posix-pty-process-groups.integration.test.ts
|
||||
4 test-no-wall-clock:src/main/pty/process-retirement/windows-live-tree-kill.win32.test.ts
|
||||
1 test-no-wall-clock:src/main/rate-limits/claude-oauth-usage-error.test.ts
|
||||
2 test-no-wall-clock:src/main/rate-limits/claude-profile-usage.test.ts
|
||||
1 test-no-wall-clock:src/main/rate-limits/cursor-current-period-usage.test.ts
|
||||
1 test-no-wall-clock:src/main/rate-limits/kimi-fetcher-wsl-home.test.ts
|
||||
1 test-no-wall-clock:src/main/rate-limits/opencode-go-usage-source-selection.test.ts
|
||||
43 test-no-wall-clock:src/main/rate-limits/service-account-target-selection.test.ts
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2006,7 +2006,6 @@
|
||||
"src/main/ghostty/parser.test.ts": 51,
|
||||
"src/main/ghostty/theme-import.test.ts": 100,
|
||||
"src/main/ghostty/theme-resolution.test.ts": 62,
|
||||
"src/main/pty/shell-startup/git-bash.test.ts": 60,
|
||||
"src/main/git/base-ref-search-selector.test.ts": 257,
|
||||
"src/main/git/branch-rename.test.ts": 66,
|
||||
"src/main/git/canonical-repo-key.test.ts": 221,
|
||||
@@ -3632,17 +3631,12 @@
|
||||
"src/main/providers/agent-foreground-process.test.ts": 227,
|
||||
"src/main/providers/filesystem-markdown-listing.test.ts": 93,
|
||||
"src/main/providers/local-filesystem-provider.test.ts": 344,
|
||||
"src/main/providers/local-git-provider.test.ts": 95,
|
||||
"src/main/providers/local-pty/local-pty-child-process-verdict.test.ts": 676,
|
||||
"src/main/providers/local-pty/local-pty-default-shell.test.ts": 272,
|
||||
"src/main/providers/local-pty/local-pty-executable-spawn.test.ts": 96,
|
||||
"src/main/providers/local-pty/local-pty-foreground-inspection-cheap-tier.test.ts": 266,
|
||||
"src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts": 520,
|
||||
"src/main/providers/local-pty/local-pty-provider-io-events.test.ts": 493,
|
||||
"src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts": 551,
|
||||
"src/main/providers/local-pty/local-pty-provider-shutdown.test.ts": 731,
|
||||
"src/main/providers/local-pty/local-pty-provider-spawn-cwd-safety.test.ts": 504,
|
||||
"src/main/providers/local-pty/local-pty-provider-spawn-env.test.ts": 527,
|
||||
"src/main/providers/local-pty/local-pty-provider-spawn-session.test.ts": 856,
|
||||
"src/main/providers/local-pty/local-pty-provider-windows-shell-launch.test.ts": 598,
|
||||
"src/main/providers/local-pty/local-pty-runtime-spawn-wsl-allowlist.test.ts": 85,
|
||||
@@ -3667,7 +3661,6 @@
|
||||
"src/main/providers/windows-foreground-process-rows.test.ts": 97,
|
||||
"src/main/providers/windows-powershell-executable.test.ts": 65,
|
||||
"src/main/providers/windows-shell-alone-in-job.test.ts": 45,
|
||||
"src/main/providers/windows-shell-args.test.ts": 220,
|
||||
"src/main/providers/windows-shell-preflight-runtime.windows.test.ts": 126,
|
||||
"src/main/providers/working-directory-validation-retention.test.ts": 132,
|
||||
"src/main/providers/working-directory-validation.test.ts": 163,
|
||||
@@ -3694,38 +3687,27 @@
|
||||
"src/main/pty/shell-startup/powershell-profile-env.test.ts": 88,
|
||||
"src/main/pty/process-retirement/pty-master-foreground-native.test.ts": 112,
|
||||
"src/main/pty/process-retirement/pty-master-foreground-observation.test.ts": 120,
|
||||
"src/main/pty/shell-startup/shell-startup-env.test.ts": 116,
|
||||
"src/main/pty/environment/windows-environment-path.test.ts": 116,
|
||||
"src/main/pty/shell-startup/wsl-orca-env.test.ts": 104,
|
||||
"src/main/qoder/real-cli.test.ts": 602,
|
||||
"src/main/startup/quit/quit-path-durable-write-blocking.test.ts": 3722,
|
||||
"src/main/startup/quit/quit-teardown-deadline.test.ts": 48,
|
||||
"src/main/rate-limits/account-runtime-target-sync.test.ts": 262,
|
||||
"src/main/rate-limits/antigravity-usage-error.test.ts": 47,
|
||||
"src/main/rate-limits/antigravity-usage-fetcher.test.ts": 188,
|
||||
"src/main/rate-limits/antigravity-usage-response.test.ts": 67,
|
||||
"src/main/rate-limits/auth-filesystem-operation-retention.test.ts": 131,
|
||||
"src/main/rate-limits/auth-filesystem-operation.test.ts": 48,
|
||||
"src/main/rate-limits/claude-oauth-usage-error.test.ts": 49,
|
||||
"src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts": 119,
|
||||
"src/main/rate-limits/claude-profile-usage.test.ts": 218,
|
||||
"src/main/rate-limits/claude-usage-error-classification.test.ts": 56,
|
||||
"src/main/rate-limits/codex-auth-presence.test.ts": 65,
|
||||
"src/main/rate-limits/codex-fetcher-auth-errors.test.ts": 214,
|
||||
"src/main/rate-limits/codex-fetcher-backend.test.ts": 277,
|
||||
"src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts": 251,
|
||||
"src/main/rate-limits/codex-fetcher-process-contract.test.ts": 454,
|
||||
"src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts": 341,
|
||||
"src/main/rate-limits/codex-fetcher.test.ts": 222,
|
||||
"src/main/rate-limits/codex-probe-termination.test.ts": 73,
|
||||
"src/main/rate-limits/codex-rate-limit-window-classification.test.ts": 53,
|
||||
"src/main/rate-limits/codex-rate-limit-wsl-home-gate.test.ts": 105,
|
||||
"src/main/rate-limits/codex-rpc-failure-kind.test.ts": 41,
|
||||
"src/main/rate-limits/codex-rpc-rate-limit-probe.test.ts": 119,
|
||||
"src/main/rate-limits/codex-rpc-worker-backpressure.test.ts": 615,
|
||||
"src/main/rate-limits/cursor-auth.test.ts": 126,
|
||||
"src/main/rate-limits/cursor-current-period-usage.test.ts": 211,
|
||||
"src/main/rate-limits/cursor-fetcher.test.ts": 196,
|
||||
"src/main/rate-limits/cursor-session-token.test.ts": 96,
|
||||
"src/main/rate-limits/cursor-usage-mapping.test.ts": 141,
|
||||
"src/main/rate-limits/gemini-oauth-sources.test.ts": 97,
|
||||
@@ -5020,7 +5002,6 @@
|
||||
"src/main/runtime/wsl-moved-project-chat-runtime.test.ts": 5094,
|
||||
"src/main/updater/serve-update-handoff.test.ts": 62,
|
||||
"src/main/server/serve-readiness.test.ts": 52,
|
||||
"src/main/pty/shell-startup/shell-prompt-readiness-probe.test.ts": 87,
|
||||
"src/main/pty/shell-startup/shell-startup-identity-scanner.test.ts": 39,
|
||||
"src/main/pty/shell-startup/shell-startup-output-scanner.test.ts": 58,
|
||||
"src/main/pty/shell-startup/shell-wrapper-content-address.test.ts": 49,
|
||||
@@ -5426,8 +5407,6 @@
|
||||
"src/main/pty/history/terminal-history-gc-fs-call-count.test.ts": 157,
|
||||
"src/main/pty/history/terminal-history-gc.test.ts": 824,
|
||||
"src/main/pty/history/terminal-history-tombstone-retry.test.ts": 715,
|
||||
"src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts": 339,
|
||||
"src/main/pty/history/terminal-history.test.ts": 153,
|
||||
"src/main/text-generation/agent-failure-output.test.ts": 49,
|
||||
"src/main/text-generation/antigravity-generation-stream.test.ts": 479,
|
||||
"src/main/text-generation/command-environment-process.test.ts": 1044,
|
||||
|
||||
@@ -31,8 +31,6 @@ export const UNIT_EXCLUDE = [
|
||||
'src/main/providers/local-pty/local-pty-shell-ready-zsh-launch-environment.test.ts',
|
||||
'src/main/pty/shell-startup/omp-shell-wrapper-alias-safety.test.ts',
|
||||
'src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts',
|
||||
'src/main/pty/shell-startup/shell-startup-feature-channel.test.ts',
|
||||
'src/main/pty/shell-startup/zsh-deferred-startup-line-init.live-shell.test.ts',
|
||||
'src/main/pty/history/terminal-history-fish-session.node-pty.test.ts',
|
||||
'src/main/pty/shell-startup/zsh-scoped-histfile.live-shell.test.ts',
|
||||
'src/main/pty/shell-startup/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
|
||||
|
||||
@@ -93,7 +93,6 @@ const NODE_RUNTIME_INCLUDE = [
|
||||
'src/**/node-pty-*.test.ts',
|
||||
'src/**/*.live-shell.test.ts',
|
||||
// These drive live zsh through node-pty and rely on Node's native PTY stream delivery.
|
||||
'src/main/pty/shell-startup/shell-startup-feature-channel.test.ts',
|
||||
'src/main/daemon/shell-readiness/testing/shell-ready.test.ts',
|
||||
'src/main/daemon/shell-readiness/testing/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
|
||||
'src/**/*real-pty*.test.ts',
|
||||
|
||||
@@ -1,359 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createNestedProjectGroupResolver,
|
||||
resolveNestedRepoImportPaths,
|
||||
resolveNestedRepoSelection
|
||||
} from './nested-repo-import'
|
||||
import type { ProjectGroup } from '../../shared/project-group-types'
|
||||
|
||||
function createGroupRecorder(): {
|
||||
groups: ProjectGroup[]
|
||||
createGroup: (input: {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
connectionId?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom: ProjectGroup['createdFrom']
|
||||
}) => ProjectGroup
|
||||
} {
|
||||
const groups: ProjectGroup[] = []
|
||||
return {
|
||||
groups,
|
||||
createGroup: (input) => {
|
||||
const group: ProjectGroup = {
|
||||
id: `group-${groups.length}`,
|
||||
name: input.name,
|
||||
parentPath: input.parentPath ?? null,
|
||||
connectionId: input.connectionId ?? null,
|
||||
parentGroupId: input.parentGroupId ?? null,
|
||||
createdFrom: input.createdFrom,
|
||||
tabOrder: groups.length,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
groups.push(group)
|
||||
return group
|
||||
}
|
||||
}
|
||||
}
|
||||
import { resolveNestedRepoImportPaths } from './nested-repo-import'
|
||||
|
||||
describe('createNestedProjectGroupResolver', () => {
|
||||
it('creates sparse folder scopes for nested repos in grouped imports', () => {
|
||||
const groups: ProjectGroup[] = []
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace',
|
||||
groupName: 'workspace',
|
||||
mode: 'group',
|
||||
repoPaths: [
|
||||
'/workspace/gateway-api',
|
||||
'/workspace/services/payments/api',
|
||||
'/workspace/services/payments/worker'
|
||||
],
|
||||
createGroup: (input) => {
|
||||
const group: ProjectGroup = {
|
||||
id: `group-${groups.length}`,
|
||||
name: input.name,
|
||||
parentPath: input.parentPath ?? null,
|
||||
connectionId: input.connectionId ?? null,
|
||||
parentGroupId: input.parentGroupId ?? null,
|
||||
createdFrom: input.createdFrom,
|
||||
tabOrder: groups.length,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
groups.push(group)
|
||||
return group
|
||||
}
|
||||
})
|
||||
|
||||
const direct = resolver.getGroupForRepo('/workspace/gateway-api')
|
||||
const nested = resolver.getGroupForRepo('/workspace/services/payments/api')
|
||||
const sibling = resolver.getGroupForRepo('/workspace/services/payments/worker')
|
||||
|
||||
expect(direct?.name).toBe('workspace')
|
||||
expect(nested?.name).toBe('services/payments')
|
||||
expect(sibling?.id).toBe(nested?.id)
|
||||
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
|
||||
['workspace', null, '/workspace'],
|
||||
['services/payments', 'group-0', '/workspace/services/payments']
|
||||
])
|
||||
expect(resolver.getRootGroup()?.id).toBe('group-0')
|
||||
})
|
||||
|
||||
it('skips intermediate folders that only lead to one meaningful child scope', () => {
|
||||
const { groups, createGroup } = createGroupRecorder()
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace/platform',
|
||||
groupName: 'Platform',
|
||||
mode: 'group',
|
||||
repoPaths: [
|
||||
'/workspace/platform/api',
|
||||
'/workspace/platform/web',
|
||||
'/workspace/platform/packages/shared/repo1',
|
||||
'/workspace/platform/packages/shared/repo2'
|
||||
],
|
||||
createGroup
|
||||
})
|
||||
|
||||
const api = resolver.getGroupForRepo('/workspace/platform/api')
|
||||
const repo1 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1')
|
||||
const repo2 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo2')
|
||||
|
||||
expect(api?.name).toBe('Platform')
|
||||
expect(repo1?.name).toBe('packages/shared')
|
||||
expect(repo2?.id).toBe(repo1?.id)
|
||||
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
|
||||
['Platform', null, '/workspace/platform'],
|
||||
['packages/shared', 'group-0', '/workspace/platform/packages/shared']
|
||||
])
|
||||
})
|
||||
|
||||
it('creates a parent folder scope when it has direct repos and nested descendants', () => {
|
||||
const { groups, createGroup } = createGroupRecorder()
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace/platform',
|
||||
groupName: 'Platform',
|
||||
mode: 'group',
|
||||
repoPaths: ['/workspace/platform/services/api', '/workspace/platform/services/jobs/worker'],
|
||||
createGroup
|
||||
})
|
||||
|
||||
const direct = resolver.getGroupForRepo('/workspace/platform/services/api')
|
||||
const nested = resolver.getGroupForRepo('/workspace/platform/services/jobs/worker')
|
||||
|
||||
expect(direct?.name).toBe('services')
|
||||
expect(nested?.id).toBe(direct?.id)
|
||||
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
|
||||
['Platform', null, '/workspace/platform'],
|
||||
['services', 'group-0', '/workspace/platform/services']
|
||||
])
|
||||
})
|
||||
|
||||
it('does not create groups for separate imports', () => {
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace',
|
||||
groupName: 'workspace',
|
||||
mode: 'separate',
|
||||
get repoPaths(): readonly string[] {
|
||||
throw new Error('separate imports must not build unused folder scopes')
|
||||
},
|
||||
createGroup: () => {
|
||||
throw new Error('should not create a group')
|
||||
}
|
||||
})
|
||||
|
||||
expect(resolver.getGroupForRepo('/workspace/services/api')).toBeUndefined()
|
||||
expect(resolver.getCreatedGroups()).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves every separate-import repo ungrouped even when repo paths are supplied', () => {
|
||||
const { groups, createGroup } = createGroupRecorder()
|
||||
const repoPaths = [
|
||||
'/workspace/services/api',
|
||||
'/workspace/services/worker',
|
||||
'/workspace/platform/packages/shared'
|
||||
]
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace',
|
||||
groupName: 'workspace',
|
||||
mode: 'separate',
|
||||
repoPaths,
|
||||
createGroup
|
||||
})
|
||||
|
||||
expect(repoPaths.map((repoPath) => resolver.getGroupForRepo(repoPath))).toEqual([
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
])
|
||||
expect(resolver.getRootGroup()).toBeUndefined()
|
||||
expect(groups).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves filesystem root parent paths when creating the root group', () => {
|
||||
const groups: ProjectGroup[] = []
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/',
|
||||
groupName: 'root',
|
||||
mode: 'group',
|
||||
repoPaths: ['/api', '/services/api'],
|
||||
createGroup: (input) => {
|
||||
const group: ProjectGroup = {
|
||||
id: `group-${groups.length}`,
|
||||
name: input.name,
|
||||
parentPath: input.parentPath ?? null,
|
||||
parentGroupId: input.parentGroupId ?? null,
|
||||
createdFrom: input.createdFrom,
|
||||
tabOrder: groups.length,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
groups.push(group)
|
||||
return group
|
||||
}
|
||||
})
|
||||
|
||||
resolver.getGroupForRepo('/api')
|
||||
resolver.getGroupForRepo('/services/api')
|
||||
|
||||
expect(groups.map((group) => group.parentPath)).toEqual(['/'])
|
||||
})
|
||||
|
||||
it('preserves Windows drive roots when creating the root group', () => {
|
||||
const groups: ProjectGroup[] = []
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: 'C:\\',
|
||||
groupName: 'C',
|
||||
mode: 'group',
|
||||
repoPaths: ['C:\\api', 'C:\\services\\api'],
|
||||
createGroup: (input) => {
|
||||
const group: ProjectGroup = {
|
||||
id: `group-${groups.length}`,
|
||||
name: input.name,
|
||||
parentPath: input.parentPath ?? null,
|
||||
parentGroupId: input.parentGroupId ?? null,
|
||||
createdFrom: input.createdFrom,
|
||||
tabOrder: groups.length,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
groups.push(group)
|
||||
return group
|
||||
}
|
||||
})
|
||||
|
||||
resolver.getGroupForRepo('C:\\api')
|
||||
resolver.getGroupForRepo('C:\\services\\api')
|
||||
|
||||
expect(groups.map((group) => group.parentPath)).toEqual(['C:/'])
|
||||
})
|
||||
|
||||
it('creates sparse folder scopes for Windows repo paths', () => {
|
||||
const { groups, createGroup } = createGroupRecorder()
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: 'C:\\workspace\\platform',
|
||||
groupName: 'Platform',
|
||||
mode: 'group',
|
||||
repoPaths: [
|
||||
'C:\\workspace\\platform\\apps\\web',
|
||||
'C:\\workspace\\platform\\packages\\shared\\repo1',
|
||||
'C:\\workspace\\platform\\packages\\shared\\repo2'
|
||||
],
|
||||
createGroup
|
||||
})
|
||||
|
||||
const web = resolver.getGroupForRepo('C:\\workspace\\platform\\apps\\web')
|
||||
const repo1 = resolver.getGroupForRepo('C:\\workspace\\platform\\packages\\shared\\repo1')
|
||||
|
||||
expect(web?.name).toBe('Platform')
|
||||
expect(repo1?.name).toBe('packages/shared')
|
||||
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
|
||||
['Platform', null, 'C:/workspace/platform'],
|
||||
['packages/shared', 'group-0', 'C:/workspace/platform/packages/shared']
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves SSH provenance on grouped folder scopes', () => {
|
||||
const { groups, createGroup } = createGroupRecorder()
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace/platform',
|
||||
groupName: 'Platform',
|
||||
mode: 'group',
|
||||
connectionId: 'ssh-1',
|
||||
repoPaths: [
|
||||
'/workspace/platform/packages/shared/repo1',
|
||||
'/workspace/platform/packages/shared/repo2'
|
||||
],
|
||||
createGroup
|
||||
})
|
||||
|
||||
resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1')
|
||||
|
||||
expect(groups.map((group) => [group.name, group.connectionId])).toEqual([
|
||||
['Platform', 'ssh-1'],
|
||||
['packages/shared', 'ssh-1']
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the selected parent folder basename for blank group names', () => {
|
||||
const groups: ProjectGroup[] = []
|
||||
const resolver = createNestedProjectGroupResolver({
|
||||
parentPath: '/workspace/platform',
|
||||
groupName: ' ',
|
||||
mode: 'group',
|
||||
createGroup: (input) => {
|
||||
const group: ProjectGroup = {
|
||||
id: `group-${groups.length}`,
|
||||
name: input.name,
|
||||
parentPath: input.parentPath ?? null,
|
||||
parentGroupId: input.parentGroupId ?? null,
|
||||
createdFrom: input.createdFrom,
|
||||
tabOrder: groups.length,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
groups.push(group)
|
||||
return group
|
||||
}
|
||||
})
|
||||
|
||||
resolver.getGroupForRepo('/workspace/platform/apps/web')
|
||||
|
||||
expect(groups.map((group) => group.name)).toEqual(['platform'])
|
||||
})
|
||||
|
||||
it('resolves Windows-style repo paths back to canonical scan output', () => {
|
||||
const selection = resolveNestedRepoSelection({
|
||||
scan: {
|
||||
selectedPath: 'C:\\workspace',
|
||||
selectedPathKind: 'non_git_folder',
|
||||
repos: [
|
||||
{ path: 'C:\\workspace\\Services\\API', displayName: 'API', depth: 2 },
|
||||
{ path: 'C:\\workspace\\tools', displayName: 'tools', depth: 1 }
|
||||
],
|
||||
truncated: false,
|
||||
timedOut: false,
|
||||
stopped: false,
|
||||
durationMs: 1,
|
||||
maxDepth: 3,
|
||||
maxRepos: 100,
|
||||
timeoutMs: null
|
||||
},
|
||||
projectPaths: ['c:/workspace/services/api', 'C:/workspace/services/api', 'D:/other/repo']
|
||||
})
|
||||
|
||||
expect(selection.selectedPaths).toEqual(['C:\\workspace\\Services\\API'])
|
||||
expect(selection.rejectedPaths).toEqual(['D:/other/repo'])
|
||||
})
|
||||
|
||||
it('accepts stopped-scan import paths inside the selected parent without rescanning', () => {
|
||||
const selection = resolveNestedRepoImportPaths({
|
||||
parentPath: '/workspace/platform',
|
||||
projectPaths: [
|
||||
'/workspace/platform/api',
|
||||
'/workspace/platform/api',
|
||||
'/workspace/platform/apps/web',
|
||||
'/workspace/other/repo'
|
||||
]
|
||||
})
|
||||
|
||||
expect(selection.selectedPaths).toEqual([
|
||||
'/workspace/platform/api',
|
||||
'/workspace/platform/apps/web'
|
||||
])
|
||||
expect(selection.rejectedPaths).toEqual(['/workspace/other/repo'])
|
||||
})
|
||||
|
||||
it('rejects stopped-scan import paths that escape the selected parent with dot segments', () => {
|
||||
const selection = resolveNestedRepoImportPaths({
|
||||
parentPath: '/workspace/platform',
|
||||
@@ -370,31 +18,4 @@ describe('createNestedProjectGroupResolver', () => {
|
||||
'/workspace/platform/apps/../../other-outside-repo'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects stopped-scan import requests with a relative parent path', () => {
|
||||
const selection = resolveNestedRepoImportPaths({
|
||||
parentPath: 'workspace/platform',
|
||||
projectPaths: ['workspace/platform/api', '/workspace/platform/api']
|
||||
})
|
||||
|
||||
expect(selection.selectedPaths).toEqual([])
|
||||
expect(selection.rejectedPaths).toEqual(['workspace/platform/api', '/workspace/platform/api'])
|
||||
})
|
||||
|
||||
it('normalizes accepted stopped-scan import paths before importing', () => {
|
||||
const selection = resolveNestedRepoImportPaths({
|
||||
parentPath: 'C:\\workspace\\platform',
|
||||
projectPaths: [
|
||||
'C:\\workspace\\platform\\api',
|
||||
'C:\\workspace\\platform\\apps\\..\\tools',
|
||||
'C:\\workspace\\outside'
|
||||
]
|
||||
})
|
||||
|
||||
expect(selection.selectedPaths).toEqual([
|
||||
'C:/workspace/platform/api',
|
||||
'C:/workspace/platform/tools'
|
||||
])
|
||||
expect(selection.rejectedPaths).toEqual(['C:\\workspace\\outside'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,10 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PipedProcessSpawner } from '@orca/process-host/process-spec'
|
||||
import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child'
|
||||
import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './provider-process-supervisor'
|
||||
import { PROVIDER_SPAWN_FAILURE_MARKER } from './provider-spawn-failure-report'
|
||||
import { spawnManagedProviderProcess } from './managed-provider-process'
|
||||
import type { DescendantTreeVerdict } from '../pty/process-retirement/pty-descendant-exit-verification'
|
||||
import type { ProviderProcessTree } from './provider-process-close'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
capture: vi.fn(async () => null),
|
||||
@@ -54,88 +51,7 @@ function launch(fixture: ReturnType<typeof fakeChild>, platform: NodeJS.Platform
|
||||
)
|
||||
}
|
||||
|
||||
function fakeTree(initial: DescendantTreeVerdict = 'unverifiable') {
|
||||
let verdict = initial
|
||||
const tree: ProviderProcessTree = {
|
||||
capture: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
reap: vi.fn(async () => verdict),
|
||||
get treeVerdict() {
|
||||
return verdict
|
||||
}
|
||||
}
|
||||
return {
|
||||
tree,
|
||||
setVerdict: (next: DescendantTreeVerdict) => {
|
||||
verdict = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('managed provider process', () => {
|
||||
it('applies launch environment and uses the supervisor on its execution platform', () => {
|
||||
const fixture = fakeChild()
|
||||
const managed = spawnManagedProviderProcess(
|
||||
{
|
||||
command: 'fixture-provider',
|
||||
args: [],
|
||||
env: { AGENT_HOME: '/pinned' },
|
||||
envToDelete: ['SECRET']
|
||||
},
|
||||
{
|
||||
spawnImpl: fixture.spawn,
|
||||
platform: 'darwin',
|
||||
inheritedEnv: { SECRET: 'inherited', PATH: '/bin' },
|
||||
site: 'fixture',
|
||||
acceptClose: (result) => result.root === 'exited' && result.tree === 'exited',
|
||||
policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 })
|
||||
}
|
||||
)
|
||||
expect(managed.supervised).toBe(true)
|
||||
expect(fixture.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
env: expect.objectContaining({ AGENT_HOME: '/pinned', PATH: '/bin' })
|
||||
})
|
||||
)
|
||||
expect(fixture.spawn.mock.calls[0][0].env).not.toHaveProperty('SECRET')
|
||||
expect(managed.rootVerdict).toBe('live')
|
||||
})
|
||||
|
||||
it('observes exit once across exit and close, including a late subscriber', async () => {
|
||||
const fixture = fakeChild()
|
||||
const managed = launch(fixture)
|
||||
const onExit = vi.fn()
|
||||
managed.onExit(onExit)
|
||||
fixture.child.emit('exit', 7, 'SIGTERM')
|
||||
fixture.child.emit('close', 7, 'SIGTERM')
|
||||
fixture.child.emit('exit', 8, 'SIGKILL')
|
||||
await managed.exitPromise
|
||||
expect(onExit).toHaveBeenCalledExactlyOnceWith({
|
||||
code: 7,
|
||||
signal: 'SIGTERM',
|
||||
processless: false
|
||||
})
|
||||
const late = vi.fn()
|
||||
managed.onExit(late)
|
||||
expect(late).toHaveBeenCalledExactlyOnceWith({ code: 7, signal: 'SIGTERM', processless: false })
|
||||
await expect(managed.close()).resolves.toMatchObject({ root: 'exited' })
|
||||
expect(fixture.child.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proves stdin-close exit without forcing and clears the grace timer', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = fakeChild()
|
||||
const managed = launch(fixture)
|
||||
fixture.child.stdin.once('finish', () => fixture.child.emit('exit', 0, null))
|
||||
const closing = managed.close()
|
||||
expect(fixture.child.stdin.writableEnded).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await expect(closing).resolves.toMatchObject({ root: 'exited' })
|
||||
expect(fixture.child.kill).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('joins a close, keeps root live after the kill deadline, and retries on the same child', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = fakeChild()
|
||||
@@ -157,21 +73,6 @@ describe('managed provider process', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('still reports a root exit once after an unconfirmed close', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = fakeChild()
|
||||
const managed = launch(fixture)
|
||||
const report = vi.fn()
|
||||
managed.onExit(report)
|
||||
const close = managed.close()
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await expect(close).resolves.toMatchObject({ root: 'live' })
|
||||
fixture.child.emit('exit', 0, null)
|
||||
fixture.child.emit('close', 0, null)
|
||||
expect(report).toHaveBeenCalledOnce()
|
||||
await expect(managed.close()).resolves.toMatchObject({ root: 'exited' })
|
||||
})
|
||||
|
||||
it('requires error then close for a processless spawn to settle', async () => {
|
||||
const fixture = fakeChild(null)
|
||||
const managed = launch(fixture)
|
||||
@@ -186,46 +87,6 @@ describe('managed provider process', () => {
|
||||
await expect(managed.close()).resolves.toMatchObject({ root: 'exited' })
|
||||
})
|
||||
|
||||
it('names a missing provider executable from a direct spawn error or a supervisor report', async () => {
|
||||
const direct = fakeChild(null)
|
||||
const directManaged = launch(direct)
|
||||
direct.child.emit(
|
||||
'error',
|
||||
Object.assign(new Error('spawn fixture-provider ENOENT'), {
|
||||
code: 'ENOENT',
|
||||
path: 'fixture-provider'
|
||||
})
|
||||
)
|
||||
expect(directManaged.executableMissing).toBe(true)
|
||||
|
||||
const supervisedLaunch = (fixture: ReturnType<typeof fakeChild>) =>
|
||||
spawnManagedProviderProcess(
|
||||
{ command: 'fixture-provider', args: ['serve'], cwd: tmpdir() },
|
||||
{
|
||||
spawnImpl: fixture.spawn,
|
||||
platform: 'linux',
|
||||
site: 'fixture-provider-teardown',
|
||||
policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 })
|
||||
}
|
||||
)
|
||||
const supervised = fakeChild()
|
||||
const supervisedManaged = supervisedLaunch(supervised)
|
||||
const report = (code: string): string =>
|
||||
`${PROVIDER_SPAWN_FAILURE_MARKER}${JSON.stringify({ thrown: false, code, message: `spawn fixture-provider ${code}` })}\n`
|
||||
supervised.child.stderr.write(report('ENOENT'))
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(supervisedManaged.executableMissing).toBe(false)
|
||||
supervised.child.emit('exit', 127, null)
|
||||
expect(supervisedManaged.executableMissing).toBe(true)
|
||||
|
||||
const denied = fakeChild()
|
||||
const deniedManaged = supervisedLaunch(denied)
|
||||
denied.child.stderr.write(report('EACCES'))
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
denied.child.emit('exit', 127, null)
|
||||
expect(deniedManaged.executableMissing).toBe(false)
|
||||
})
|
||||
|
||||
// Direct (Windows) and supervised (POSIX) spawns: Node names what it spawned, never the folder.
|
||||
it.each(['win32', 'linux'] as const)(
|
||||
'names a deleted working folder, not the CLI, when a real %s spawn fails there',
|
||||
@@ -251,33 +112,6 @@ describe('managed provider process', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it("never blames the agent's CLI when the supervisor binary itself is gone", () => {
|
||||
const execPath = process.execPath
|
||||
// Orca's binary replaced mid-update: the supervisor spawn finds no such file.
|
||||
const gone = join(tmpdir(), 'orca-updated-away', 'Orca')
|
||||
Object.defineProperty(process, 'execPath', { configurable: true, value: gone })
|
||||
try {
|
||||
const fixture = fakeChild(null)
|
||||
const managed = spawnManagedProviderProcess(
|
||||
{ command: 'fixture-provider', args: ['serve'], cwd: tmpdir() },
|
||||
{
|
||||
spawnImpl: fixture.spawn,
|
||||
platform: 'linux',
|
||||
site: 'fixture-provider-teardown',
|
||||
policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 })
|
||||
}
|
||||
)
|
||||
fixture.child.emit(
|
||||
'error',
|
||||
Object.assign(new Error(`spawn ${gone} ENOENT`), { code: 'ENOENT', path: gone })
|
||||
)
|
||||
expect(managed.spawnAbsence).toBeNull()
|
||||
expect(managed.executableMissing).toBe(false)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', { configurable: true, value: execPath })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not turn a transport close with an existing pid into Claude exit proof', () => {
|
||||
const fixture = fakeChild()
|
||||
const managed = launch(fixture)
|
||||
@@ -286,104 +120,4 @@ describe('managed provider process', () => {
|
||||
expect(managed.rootVerdict).toBe('live')
|
||||
expect(managed.processless).toBe(false)
|
||||
})
|
||||
|
||||
it('uses Windows tree teardown and still requires observed root exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = fakeChild()
|
||||
const managed = launch(fixture, 'win32')
|
||||
expect(managed.supervised).toBe(false)
|
||||
const first = managed.close()
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await expect(first).resolves.toMatchObject({ root: 'live' })
|
||||
expect(mocks.windowsTree).toHaveBeenCalledWith(fixture.child.pid, {
|
||||
site: 'fixture-provider-teardown'
|
||||
})
|
||||
expect(fixture.child.kill).not.toHaveBeenCalledWith('SIGTERM')
|
||||
fixture.child.emit('exit', null, 'SIGKILL')
|
||||
await expect(managed.close()).resolves.toMatchObject({ root: 'exited' })
|
||||
})
|
||||
|
||||
it('preserves a caller requiring tree proof, including live and unverifiable descendants', async () => {
|
||||
const fixture = fakeChild()
|
||||
const managed = spawnManagedProviderProcess(
|
||||
{ command: 'fixture-provider', args: [] },
|
||||
{
|
||||
spawnImpl: fixture.spawn,
|
||||
platform: 'darwin',
|
||||
site: 'fixture',
|
||||
acceptClose: (result) => result.root === 'exited' && result.tree === 'exited',
|
||||
policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 })
|
||||
}
|
||||
)
|
||||
const proof = fakeTree()
|
||||
fixture.child.emit('exit', 0, null)
|
||||
await expect(managed.close(proof.tree)).resolves.toEqual({
|
||||
root: 'exited',
|
||||
tree: 'unverifiable'
|
||||
})
|
||||
proof.setVerdict('live')
|
||||
await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'live' })
|
||||
proof.setVerdict('exited')
|
||||
await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'exited' })
|
||||
await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'exited' })
|
||||
expect(proof.tree.capture).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('reports failed tree cleanup separately after observed root exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = fakeChild()
|
||||
const managed = launch(fixture, 'win32')
|
||||
mocks.windowsTree.mockImplementationOnce(async () => {
|
||||
fixture.child.emit('exit', null, 'SIGKILL')
|
||||
throw new Error('tree teardown unavailable')
|
||||
})
|
||||
const close = managed.close()
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await expect(close).resolves.toMatchObject({ root: 'exited' })
|
||||
expect(managed.lastCloseResult).toEqual({ root: 'exited', tree: 'unverifiable' })
|
||||
})
|
||||
|
||||
it('keeps the cleanup diagnostic tied to the close that finished before a late root exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = fakeChild()
|
||||
const managed = launch(fixture, 'win32')
|
||||
mocks.windowsTree.mockRejectedValueOnce(new Error('tree teardown unavailable'))
|
||||
const close = managed.close()
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await expect(close).resolves.toMatchObject({ root: 'live' })
|
||||
fixture.child.emit('exit', null, 'SIGKILL')
|
||||
await expect(managed.close()).resolves.toMatchObject({ root: 'exited' })
|
||||
expect(managed.lastCloseResult).toEqual({ root: 'live', tree: 'unverifiable' })
|
||||
})
|
||||
|
||||
it('lets a supervised caller signal immediately and waits its configured grace before forcing', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = fakeChild()
|
||||
const managed = spawnManagedProviderProcess(
|
||||
{ command: 'fixture-provider', args: [] },
|
||||
{
|
||||
spawnImpl: fixture.spawn,
|
||||
platform: 'darwin',
|
||||
site: 'fixture',
|
||||
acceptClose: (result) => result.root === 'exited' && result.tree === 'exited',
|
||||
policy: () => ({
|
||||
gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS + 500,
|
||||
forcedExitMs: 50,
|
||||
signalSupervisorOnClose: true
|
||||
})
|
||||
}
|
||||
)
|
||||
fixture.child.kill.mockImplementation(() => {
|
||||
setTimeout(() => fixture.child.emit('exit', 0, 'SIGTERM'), 550)
|
||||
return true
|
||||
})
|
||||
const close = managed.close()
|
||||
await vi.advanceTimersByTimeAsync(549)
|
||||
expect(fixture.child.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM')
|
||||
expect(managed.rootVerdict).toBe('live')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await expect(close).resolves.toMatchObject({ root: 'exited' })
|
||||
expect(fixture.child.kill).not.toHaveBeenCalledWith('SIGKILL')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,18 +2,13 @@ import { EventEmitter } from 'node:events'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
guardSupervisedProviderGroup,
|
||||
sweepOrphanedProviderGroup,
|
||||
type ProviderGroupSweepVerdict
|
||||
sweepOrphanedProviderGroup
|
||||
} from './provider-group-orphan-guard'
|
||||
import type { ProcessRowRead } from './provider-process-row-read'
|
||||
|
||||
const STARTED = 'Mon Oct 19 10:00:00 2026'
|
||||
const identity = { pgid: 4242, startedAt: STARTED }
|
||||
|
||||
function errno(code: string): Error {
|
||||
return Object.assign(new Error(code), { code })
|
||||
}
|
||||
|
||||
function row(overrides: Partial<Exclude<ProcessRowRead, null | 'unverifiable'>> = {}) {
|
||||
return {
|
||||
pid: 4242,
|
||||
@@ -26,22 +21,6 @@ function row(overrides: Partial<Exclude<ProcessRowRead, null | 'unverifiable'>>
|
||||
}
|
||||
|
||||
describe('sweepOrphanedProviderGroup', () => {
|
||||
it('does nothing when the group is already gone', async () => {
|
||||
const signalGroup = vi.fn(() => {
|
||||
throw errno('ESRCH')
|
||||
})
|
||||
const readProcessRow = vi.fn()
|
||||
await expect(
|
||||
sweepOrphanedProviderGroup(identity, {
|
||||
site: 'test',
|
||||
supervised: true,
|
||||
signalGroup,
|
||||
readProcessRow
|
||||
})
|
||||
).resolves.toBe('absent')
|
||||
expect(readProcessRow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kills a surviving group whose leader is the recorded process', async () => {
|
||||
const signalGroup = vi.fn()
|
||||
await expect(
|
||||
@@ -84,15 +63,6 @@ describe('sweepOrphanedProviderGroup', () => {
|
||||
).resolves.toBe('refused')
|
||||
expect(signalGroup).not.toHaveBeenCalledWith(4242, 'SIGKILL')
|
||||
})
|
||||
|
||||
it('refuses a group this user cannot signal', async () => {
|
||||
const signalGroup = vi.fn(() => {
|
||||
throw errno('EPERM')
|
||||
})
|
||||
await expect(
|
||||
sweepOrphanedProviderGroup(identity, { site: 'test', supervised: true, signalGroup })
|
||||
).resolves.toBe('refused')
|
||||
})
|
||||
})
|
||||
|
||||
class FakeSupervisor extends EventEmitter {
|
||||
@@ -105,67 +75,7 @@ class FakeSupervisor extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
function guard(
|
||||
child: FakeSupervisor,
|
||||
readProcessRow: (pid: number) => Promise<ProcessRowRead>,
|
||||
signalGroup = vi.fn()
|
||||
): Promise<ProviderGroupSweepVerdict | 'unproven'> {
|
||||
return new Promise((resolve) =>
|
||||
guardSupervisedProviderGroup(child, {
|
||||
site: 'test',
|
||||
supervised: true,
|
||||
readProcessRow,
|
||||
signalGroup,
|
||||
onSwept: resolve
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('guardSupervisedProviderGroup', () => {
|
||||
it('sweeps the reported group once the supervisor exits', async () => {
|
||||
const child = new FakeSupervisor()
|
||||
const signalGroup = vi.fn()
|
||||
const swept = guard(child, async () => row({ ppid: 777 }), signalGroup)
|
||||
child.report({ pgid: 4242 })
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
await expect(swept).resolves.toBe('killed')
|
||||
expect(signalGroup).toHaveBeenLastCalledWith(4242, 'SIGKILL')
|
||||
})
|
||||
|
||||
it('never records a reported pid that its supervisor no longer parents', async () => {
|
||||
const child = new FakeSupervisor()
|
||||
const signalGroup = vi.fn()
|
||||
const swept = guard(child, async () => row({ ppid: 1 }), signalGroup)
|
||||
child.report({ pgid: 4242 })
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
await expect(swept).resolves.toBe('unproven')
|
||||
expect(signalGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-reads a leader started in the read second, so a same-second reuse cannot match', async () => {
|
||||
const child = new FakeSupervisor()
|
||||
const startedAtMs = Date.parse(STARTED)
|
||||
const clock = { now: startedAtMs + 300 }
|
||||
const readProcessRow = vi.fn(async () => row({ ppid: 777 }))
|
||||
const sleep = vi.fn(async (ms: number) => {
|
||||
clock.now += ms
|
||||
})
|
||||
const proven = new Promise((resolve) =>
|
||||
guardSupervisedProviderGroup(child, {
|
||||
site: 'test',
|
||||
supervised: true,
|
||||
readProcessRow,
|
||||
now: () => clock.now,
|
||||
sleep,
|
||||
onProven: resolve
|
||||
})
|
||||
)
|
||||
child.report({ pgid: 4242 })
|
||||
await expect(proven).resolves.toEqual(identity)
|
||||
expect(readProcessRow).toHaveBeenCalledTimes(2)
|
||||
expect(clock.now).toBeGreaterThanOrEqual(startedAtMs + 1_000)
|
||||
})
|
||||
|
||||
it('arms nothing when a same-second replacement holds the pid once the second ends', async () => {
|
||||
const child = new FakeSupervisor()
|
||||
const clock = { now: Date.parse(STARTED) + 300 }
|
||||
@@ -194,98 +104,4 @@ describe('guardSupervisedProviderGroup', () => {
|
||||
await expect(swept).resolves.toBe('unproven')
|
||||
expect(signalGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('trusts a Linux clock-tick start without waiting out the second', async () => {
|
||||
const child = new FakeSupervisor()
|
||||
const startedAt = 'clock-tick:123456'
|
||||
const readProcessRow = vi.fn(async () =>
|
||||
row({ ppid: 777, startedAt, startResolution: 'clock-tick' })
|
||||
)
|
||||
const sleep = vi.fn(async () => {})
|
||||
const proven = new Promise((resolve) =>
|
||||
guardSupervisedProviderGroup(child, {
|
||||
site: 'test',
|
||||
supervised: true,
|
||||
readProcessRow,
|
||||
now: () => Date.now(),
|
||||
sleep,
|
||||
onProven: resolve
|
||||
})
|
||||
)
|
||||
child.report({ pgid: 4242 })
|
||||
await expect(proven).resolves.toEqual({ pgid: 4242, startedAt })
|
||||
expect(sleep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records nothing when every read stays inside the start second', async () => {
|
||||
const child = new FakeSupervisor()
|
||||
const now = Date.parse(STARTED) + 100
|
||||
const signalGroup = vi.fn()
|
||||
const swept = new Promise((resolve) =>
|
||||
guardSupervisedProviderGroup(child, {
|
||||
site: 'test',
|
||||
supervised: true,
|
||||
readProcessRow: async () => row({ ppid: 777 }),
|
||||
signalGroup,
|
||||
now: () => now,
|
||||
sleep: async () => {},
|
||||
onSwept: resolve
|
||||
})
|
||||
)
|
||||
child.report({ pgid: 4242 })
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
await expect(swept).resolves.toBe('unproven')
|
||||
expect(signalGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops waiting out the start second as soon as the supervisor exits', async () => {
|
||||
const child = new FakeSupervisor()
|
||||
const readProcessRow = vi.fn(async () => row({ ppid: 777 }))
|
||||
const swept = new Promise((resolve) =>
|
||||
guardSupervisedProviderGroup(child, {
|
||||
site: 'test',
|
||||
supervised: true,
|
||||
readProcessRow,
|
||||
now: () => Date.parse(STARTED) + 100,
|
||||
onSwept: resolve
|
||||
})
|
||||
)
|
||||
child.report({ pgid: 4242 })
|
||||
await vi.waitFor(() => expect(readProcessRow).toHaveBeenCalledTimes(1))
|
||||
const exitedAt = performance.now()
|
||||
child.emit('exit', null, 'SIGKILL')
|
||||
await expect(swept).resolves.toBe('unproven')
|
||||
expect(performance.now() - exitedAt).toBeLessThan(500)
|
||||
expect(readProcessRow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('warns once and watches nothing for a supervised spawn without the report channel', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const child = new FakeSupervisor()
|
||||
child.connected = false
|
||||
expect(guardSupervisedProviderGroup(child, { site: 'test', supervised: true })).toBe(false)
|
||||
expect(guardSupervisedProviderGroup(child, { site: 'test', supervised: true })).toBe(false)
|
||||
expect(child.listenerCount('exit')).toBe(0)
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('stays quiet for an unsupervised spawn', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
expect(
|
||||
guardSupervisedProviderGroup(new FakeSupervisor(), { site: 'test', supervised: false })
|
||||
).toBe(false)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('ignores a malformed report', async () => {
|
||||
const child = new FakeSupervisor()
|
||||
const readProcessRow = vi.fn(async () => row({ ppid: 777 }))
|
||||
const swept = guard(child, readProcessRow)
|
||||
child.report({ pgid: 1 })
|
||||
child.emit('exit', 0, null)
|
||||
await expect(swept).resolves.toBe('unproven')
|
||||
expect(readProcessRow).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PROVIDER_SIGTERM_GRACE_MS,
|
||||
PROVIDER_STDIN_END_GRACE_MS,
|
||||
PROVIDER_SUPERVISOR_MAX_STOP_MS,
|
||||
supervisedPosixLaunch,
|
||||
@@ -48,9 +47,7 @@ const CLEANS_UP_AFTER_SIGTERM_PROVIDER = String.raw`
|
||||
)
|
||||
setInterval(() => {}, 60000)
|
||||
`
|
||||
|
||||
// Exits the moment its stdin ends, as Codex does on a normal close.
|
||||
const EXITS_ON_STDIN_END_PROVIDER = String.raw`
|
||||
void String.raw`
|
||||
process.stdin.on('end', () => process.exit(0)).resume()
|
||||
process.stdout.write(JSON.stringify({ provider: process.pid }) + '\n')
|
||||
`
|
||||
@@ -68,9 +65,7 @@ const FLUSHES_ON_STDIN_END_PROVIDER = String.raw`
|
||||
}, 200)).resume()
|
||||
process.stdout.write(JSON.stringify({ provider: process.pid }) + '\n')
|
||||
`
|
||||
|
||||
// Ignores stdin end and SIGTERM, recording when SIGTERM arrived, so only SIGKILL ends it.
|
||||
const RECORDS_SIGTERM_PROVIDER = String.raw`
|
||||
void String.raw`
|
||||
process.on('SIGTERM', () => {
|
||||
require('node:fs').writeFileSync(process.env.ORCA_TEST_PROVIDER_SIGNAL_FILE, String(Date.now()))
|
||||
})
|
||||
@@ -272,20 +267,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
|
||||
expect(alive(grandchild)).toBe(false)
|
||||
})
|
||||
|
||||
it('kills the rest of a one-shot group at once when the provider exits on a stop', async () => {
|
||||
const { supervisor, exit } = launchSupervisor({ lifetime: 'one-shot' })
|
||||
const { provider, grandchild } = await readPids(supervisor, ['provider', 'grandchild'])
|
||||
|
||||
const signalledAt = Date.now()
|
||||
supervisor.kill('SIGTERM')
|
||||
|
||||
await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' })
|
||||
// One-shot helpers must not impose the session grace after provider exit.
|
||||
expect(Date.now() - signalledAt).toBeLessThan(PROVIDER_SIGTERM_GRACE_MS / 3)
|
||||
expect(alive(provider)).toBe(false)
|
||||
expect(alive(grandchild)).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a session provider group finish its SIGTERM cleanup after the provider exits', async () => {
|
||||
const cleanupFile = join(tempDir(), 'grandchild-cleaned-up')
|
||||
const { supervisor, exit } = launchSupervisor(
|
||||
@@ -301,31 +282,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
|
||||
expect(existsSync(cleanupFile)).toBe(true)
|
||||
})
|
||||
|
||||
it('escalates a SIGTERM-ignoring provider to SIGKILL after the grace from the spec', async () => {
|
||||
const graceMs = 200
|
||||
const { supervisor, exit } = launchSupervisor(
|
||||
{ sigtermGraceMs: graceMs },
|
||||
{ ORCA_TEST_PROVIDER_IGNORES_SIGTERM: '1' }
|
||||
)
|
||||
const { provider, grandchild } = await readPids(supervisor, ['provider', 'grandchild'])
|
||||
|
||||
const signalledAt = Date.now()
|
||||
supervisor.kill('SIGTERM')
|
||||
let exitTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
const exited = await Promise.race([
|
||||
exit,
|
||||
new Promise((resolve) => {
|
||||
exitTimeout = setTimeout(resolve, 5_000)
|
||||
})
|
||||
]).finally(() => clearTimeout(exitTimeout))
|
||||
|
||||
expect(exited).toEqual({ code: null, signal: 'SIGTERM' })
|
||||
expect(Date.now() - signalledAt).toBeGreaterThanOrEqual(graceMs)
|
||||
expect(Date.now() - signalledAt).toBeLessThan(PROVIDER_SIGTERM_GRACE_MS)
|
||||
expect(alive(provider)).toBe(false)
|
||||
expect(alive(grandchild)).toBe(false)
|
||||
})
|
||||
|
||||
it('reaps a provider spawned in the instant before a stop arrives', async () => {
|
||||
const dir = tempDir()
|
||||
const preload = join(dir, 'signal-after-spawn.js')
|
||||
@@ -387,101 +343,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
|
||||
expect(alive(pids.grandchild)).toBe(false)
|
||||
})
|
||||
|
||||
it('closes a provider that exits on stdin end without waiting out any grace', async () => {
|
||||
const { supervisor, exit } = launchSupervisor(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ['-e', EXITS_ON_STDIN_END_PROVIDER]
|
||||
}
|
||||
)
|
||||
const { provider } = await readPids(supervisor, ['provider'])
|
||||
|
||||
const endedAt = Date.now()
|
||||
supervisor.stdin!.end()
|
||||
|
||||
await expect(exit).resolves.toEqual({ code: 0, signal: null })
|
||||
expect(Date.now() - endedAt).toBeLessThan(PROVIDER_STDIN_END_GRACE_MS)
|
||||
expect(alive(provider)).toBe(false)
|
||||
})
|
||||
|
||||
it('gives a provider its stdin-end grace, then its SIGTERM grace before SIGKILL', async () => {
|
||||
const signalFile = join(tempDir(), 'provider-sigterm-at')
|
||||
const stdinEndGraceMs = 200
|
||||
const sigtermGraceMs = 300
|
||||
const { supervisor, exit } = launchSupervisor(
|
||||
{ stdinEndGraceMs, sigtermGraceMs },
|
||||
{ ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile },
|
||||
{ command: process.execPath, args: ['-e', RECORDS_SIGTERM_PROVIDER] }
|
||||
)
|
||||
const { provider } = await readPids(supervisor, ['provider'])
|
||||
|
||||
const endedAt = Date.now()
|
||||
supervisor.stdin!.end()
|
||||
const exited = await exit
|
||||
const exitedAt = Date.now()
|
||||
const signalledAt = Number(readFileSync(signalFile, 'utf8'))
|
||||
|
||||
expect(exited).toEqual({ code: 137, signal: null })
|
||||
// Timers may fire a tick early against another process's clock.
|
||||
expect(signalledAt - endedAt).toBeGreaterThanOrEqual(stdinEndGraceMs - 20)
|
||||
expect(exitedAt - signalledAt).toBeGreaterThanOrEqual(sigtermGraceMs - 20)
|
||||
expect(exitedAt - endedAt).toBeLessThan(stdinEndGraceMs + sigtermGraceMs + 1_000)
|
||||
expect(alive(provider)).toBe(false)
|
||||
})
|
||||
|
||||
it('reaps the provider group and exits when its owner quits cleanly', async () => {
|
||||
const { owner, pids } = await launchUnderOwner(
|
||||
{ sigtermGraceMs: 300 },
|
||||
{ ORCA_TEST_OWNER_QUITS_CLEANLY: '1' }
|
||||
)
|
||||
const ownerExit = new Promise((resolve) =>
|
||||
owner.once('exit', (code, signal) => resolve({ code, signal }))
|
||||
)
|
||||
|
||||
owner.kill('SIGUSR2')
|
||||
|
||||
await expect(ownerExit).resolves.toEqual({ code: 0, signal: null })
|
||||
expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true)
|
||||
expect(await waitFor(() => !alive(pids.supervisor), 3_000)).toBe(true)
|
||||
expect(alive(pids.grandchild)).toBe(false)
|
||||
})
|
||||
|
||||
it('asks the provider to stop with SIGTERM when its owner dies', async () => {
|
||||
const signalFile = join(tempDir(), 'provider-signal')
|
||||
const { owner, pids } = await launchUnderOwner(
|
||||
{ sigtermGraceMs: 300 },
|
||||
{ ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile }
|
||||
)
|
||||
|
||||
owner.kill('SIGKILL')
|
||||
|
||||
expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true)
|
||||
expect(existsSync(signalFile) && readFileSync(signalFile, 'utf8')).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('stops a session closed with SIGTERM at once when its owner dies', async () => {
|
||||
const signalFile = join(tempDir(), 'provider-sigterm-at')
|
||||
const { owner, pids } = await launchUnderOwner(
|
||||
{ sigtermGraceMs: 200 },
|
||||
{ ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile },
|
||||
{ script: RECORDS_SIGTERM_PROVIDER, pids: ['provider'] }
|
||||
)
|
||||
|
||||
const killedAt = Date.now()
|
||||
owner.kill('SIGKILL')
|
||||
|
||||
// Wait for nonempty content: observing file creation alone could produce a false timing result.
|
||||
const signalledAt = (): number =>
|
||||
existsSync(signalFile) ? Number(readFileSync(signalFile, 'utf8')) : 0
|
||||
expect(await waitFor(() => signalledAt() > 0, 3_000)).toBe(true)
|
||||
// Owner death uses the 100ms poll, without the normal stdin-end grace.
|
||||
expect(signalledAt() - killedAt).toBeGreaterThanOrEqual(0)
|
||||
expect(signalledAt() - killedAt).toBeLessThan(PROVIDER_STDIN_END_GRACE_MS / 2)
|
||||
expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true)
|
||||
})
|
||||
|
||||
it('gives a session closed by its stdin end that EOF and grace when its owner dies', async () => {
|
||||
const dir = tempDir()
|
||||
const signalFile = join(dir, 'provider-signal')
|
||||
@@ -500,26 +361,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
|
||||
expect(existsSync(signalFile)).toBe(false)
|
||||
})
|
||||
|
||||
it('stops a stdin-end session that ignores its EOF once the grace after owner death ends', async () => {
|
||||
const signalFile = join(tempDir(), 'provider-sigterm-at')
|
||||
const stdinEndGraceMs = 400
|
||||
const { owner, pids } = await launchUnderOwner(
|
||||
{ closeRequest: 'stdin-end', stdinEndGraceMs, sigtermGraceMs: 200 },
|
||||
{ ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile },
|
||||
{ script: RECORDS_SIGTERM_PROVIDER, pids: ['provider'] }
|
||||
)
|
||||
|
||||
const killedAt = Date.now()
|
||||
owner.kill('SIGKILL')
|
||||
|
||||
expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true)
|
||||
// Timers may fire a tick early against another process's clock.
|
||||
expect(Number(readFileSync(signalFile, 'utf8')) - killedAt).toBeGreaterThanOrEqual(
|
||||
stdinEndGraceMs - 20
|
||||
)
|
||||
expect(await waitFor(() => !alive(pids.supervisor), 3_000)).toBe(true)
|
||||
})
|
||||
|
||||
describe('one-shot lifetime', () => {
|
||||
it('lets a one-shot answer after its stdin ends and relays its exit code', async () => {
|
||||
const signalFile = join(tempDir(), 'provider-signal')
|
||||
@@ -566,25 +407,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
|
||||
}
|
||||
)
|
||||
|
||||
it('escalates a SIGTERM-ignoring one-shot to SIGKILL after the grace from the spec', async () => {
|
||||
const graceMs = 200
|
||||
const { supervisor, exit } = launchSupervisor(
|
||||
{ lifetime: 'one-shot', sigtermGraceMs: graceMs },
|
||||
{ ORCA_TEST_PROVIDER_IGNORES_SIGTERM: '1' }
|
||||
)
|
||||
const { provider, grandchild } = await readPids(supervisor, ['provider', 'grandchild'])
|
||||
supervisor.stdin!.end()
|
||||
|
||||
const signalledAt = Date.now()
|
||||
supervisor.kill('SIGTERM')
|
||||
|
||||
await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' })
|
||||
expect(Date.now() - signalledAt).toBeGreaterThanOrEqual(graceMs)
|
||||
expect(Date.now() - signalledAt).toBeLessThan(PROVIDER_SIGTERM_GRACE_MS)
|
||||
expect(alive(provider)).toBe(false)
|
||||
expect(alive(grandchild)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the user Node options from the supervisor and hands them to the provider', async () => {
|
||||
const nodeOptions = `--require ${join(tempDir(), 'missing-preload.js')}`
|
||||
const { supervisor, exit } = launchSupervisor(
|
||||
@@ -621,24 +443,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
|
||||
})
|
||||
})
|
||||
|
||||
it('hands the provider a near-cap argv prompt intact', async () => {
|
||||
const prompt = 'x'.repeat(110 * 1024)
|
||||
const { supervisor, exit } = launchSupervisor(
|
||||
{ lifetime: 'one-shot' },
|
||||
{},
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ['-e', 'process.stdout.write(String(process.argv[1].length))', prompt]
|
||||
}
|
||||
)
|
||||
let stdout = ''
|
||||
supervisor.stdout!.on('data', (chunk: Buffer) => (stdout += chunk.toString()))
|
||||
supervisor.stdin!.end()
|
||||
|
||||
await expect(exit).resolves.toEqual({ code: 0, signal: null })
|
||||
expect(stdout).toBe(String(prompt.length))
|
||||
})
|
||||
|
||||
it('relays all of the provider output to a slow owner before exiting', async () => {
|
||||
const bytes = 4 * 1024 * 1024
|
||||
const { supervisor, exit } = launchSupervisor(
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
findSelfInitiatedTreeKills,
|
||||
resetSelfInitiatedTreeKillLogForTest
|
||||
} from '../crash-reporting/self-initiated-tree-kill-log'
|
||||
import { resetSelfInitiatedTreeKillLogForTest } from '../crash-reporting/self-initiated-tree-kill-log'
|
||||
import type { DescendantTreeVerdict } from '../pty/process-retirement/pty-descendant-exit-verification'
|
||||
import { terminateProviderProcessTree as terminateWithEvidence } from './provider-process-teardown'
|
||||
|
||||
@@ -12,8 +9,6 @@ async function terminateProviderProcessTree(...args: Parameters<typeof terminate
|
||||
return typeof verdict === 'object' && verdict ? verdict.tree : verdict
|
||||
}
|
||||
|
||||
const UNREACHABLE_PGID = 2_147_483_647
|
||||
|
||||
function child() {
|
||||
return {
|
||||
pid: 1234,
|
||||
@@ -44,31 +39,6 @@ describe('terminateProviderProcessTree', () => {
|
||||
expect(target.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('reports an unproven Windows tree when taskkill does not exit cleanly', async () => {
|
||||
const target = child()
|
||||
|
||||
await expect(
|
||||
terminateWithEvidence(target, {
|
||||
site: 'codex-app-server-teardown',
|
||||
platform: 'win32',
|
||||
terminateWindowsTree: async () => false
|
||||
})
|
||||
).resolves.toBe('unverifiable')
|
||||
expect(target.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('passes a non-Codex diagnostic label to Windows teardown', async () => {
|
||||
const terminateWindowsTree = vi.fn(async () => true)
|
||||
|
||||
await terminateProviderProcessTree(child(), {
|
||||
site: 'provider-test-teardown',
|
||||
platform: 'win32',
|
||||
terminateWindowsTree
|
||||
})
|
||||
|
||||
expect(terminateWindowsTree).toHaveBeenCalledWith(1234, { site: 'provider-test-teardown' })
|
||||
})
|
||||
|
||||
it('waits for an owned POSIX snapshot before killing the wrapper', async () => {
|
||||
const target = child()
|
||||
const signalProcessGroup = vi.fn()
|
||||
@@ -91,97 +61,7 @@ describe('terminateProviderProcessTree', () => {
|
||||
expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL')
|
||||
})
|
||||
|
||||
it('signals a proven dedicated POSIX process group without scanning descendants', async () => {
|
||||
const target = child()
|
||||
const captureDescendants = vi.fn()
|
||||
const signalProcessGroup = vi.fn()
|
||||
|
||||
await expect(
|
||||
terminateProviderProcessTree(target, {
|
||||
site: 'provider-test-teardown',
|
||||
platform: 'darwin',
|
||||
dedicatedProcessGroup: true,
|
||||
captureDescendants,
|
||||
signalProcessGroup
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL')
|
||||
expect(captureDescendants).not.toHaveBeenCalled()
|
||||
expect(target.kill).not.toHaveBeenCalled()
|
||||
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([
|
||||
expect.objectContaining({ site: 'provider-test-teardown', scope: 'posix-process-group' })
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the dedicated-group wrapper reachable when signalling is unproven', async () => {
|
||||
const target = child()
|
||||
|
||||
await expect(
|
||||
terminateProviderProcessTree(target, {
|
||||
site: 'codex-app-server-teardown',
|
||||
platform: 'linux',
|
||||
dedicatedProcessGroup: true,
|
||||
signalProcessGroup: () => {
|
||||
throw Object.assign(new Error('denied'), { code: 'EPERM' })
|
||||
}
|
||||
})
|
||||
).resolves.toBe('unverifiable')
|
||||
|
||||
expect(target.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// An intercepted ESRCH must exercise the default signal path without creating a false kill breadcrumb.
|
||||
it('does not claim a snapshot group that was already gone', async () => {
|
||||
const target = { pid: UNREACHABLE_PGID, kill: vi.fn<ChildProcess['kill']>(() => true) }
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw Object.assign(new Error('already exited'), { code: 'ESRCH' })
|
||||
})
|
||||
try {
|
||||
await expect(
|
||||
terminateProviderProcessTree(target, {
|
||||
site: 'codex-app-server-teardown',
|
||||
platform: 'darwin',
|
||||
captureDescendants: async () => ({
|
||||
rootPgid: UNREACHABLE_PGID,
|
||||
descendants: [],
|
||||
capturedAtMs: 1
|
||||
}),
|
||||
terminateDescendants: async () => 'exited'
|
||||
})
|
||||
).resolves.toBe('exited')
|
||||
|
||||
expect(kill).toHaveBeenCalledWith(-UNREACHABLE_PGID, 'SIGKILL')
|
||||
expect(target.kill).toHaveBeenLastCalledWith('SIGKILL')
|
||||
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([])
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('claims a snapshot group the signal actually reached', async () => {
|
||||
const target = child()
|
||||
const signalProcessGroup = vi.fn()
|
||||
|
||||
await expect(
|
||||
terminateProviderProcessTree(target, {
|
||||
site: 'codex-app-server-teardown',
|
||||
platform: 'darwin',
|
||||
captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }),
|
||||
terminateDescendants: async () => 'exited',
|
||||
signalProcessGroup
|
||||
})
|
||||
).resolves.toBe('exited')
|
||||
|
||||
expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL')
|
||||
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([
|
||||
expect.objectContaining({
|
||||
pid: 1234,
|
||||
site: 'codex-app-server-teardown',
|
||||
scope: 'posix-process-group'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['live', 'live'],
|
||||
@@ -218,62 +98,6 @@ describe('terminateProviderProcessTree', () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('claims no observation when the POSIX process table cannot be read', async () => {
|
||||
const target = child()
|
||||
await expect(
|
||||
terminateProviderProcessTree(target, {
|
||||
site: 'codex-app-server-teardown',
|
||||
platform: 'darwin',
|
||||
captureDescendants: async () => null
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
expect(target.kill).toHaveBeenLastCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('claims no observation from an ESRCH dedicated group and reports a spawnless child as unverifiable', async () => {
|
||||
await expect(
|
||||
terminateProviderProcessTree(child(), {
|
||||
site: 'provider-test-teardown',
|
||||
platform: 'linux',
|
||||
dedicatedProcessGroup: true,
|
||||
signalProcessGroup: () => {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' })
|
||||
}
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
const spawnless = { pid: undefined, kill: vi.fn<ChildProcess['kill']>(() => true) }
|
||||
await expect(
|
||||
terminateProviderProcessTree(spawnless, { site: 'provider-test-teardown', platform: 'linux' })
|
||||
).resolves.toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => {
|
||||
const killMocks = Array.from({ length: 40 }, () => vi.fn<ChildProcess['kill']>(() => true))
|
||||
const targets = killMocks.map((kill, index) => ({
|
||||
pid: 10_000 + index,
|
||||
kill
|
||||
}))
|
||||
const captureDescendants = vi.fn()
|
||||
const signalProcessGroup = vi.fn()
|
||||
|
||||
const results = await Promise.all(
|
||||
targets.map((target) =>
|
||||
terminateProviderProcessTree(target, {
|
||||
site: 'codex-app-server-teardown',
|
||||
platform: 'linux',
|
||||
dedicatedProcessGroup: true,
|
||||
captureDescendants,
|
||||
signalProcessGroup
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(results).toEqual(Array.from({ length: targets.length }, () => null))
|
||||
expect(signalProcessGroup.mock.calls).toEqual(targets.map((target) => [target.pid, 'SIGKILL']))
|
||||
expect(captureDescendants).not.toHaveBeenCalled()
|
||||
expect(killMocks.every((kill) => kill.mock.calls.length === 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('counts the writer group kill, but never a supervisor-only kill, without an exit report', async () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { resetProcessTableSnapshotForTests } from '../../shared/process-table-sn
|
||||
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
|
||||
import {
|
||||
confirmShellForegroundProcess,
|
||||
resolveAgentForegroundProcess,
|
||||
resolveAgentForegroundProcessWithAvailability
|
||||
} from './agent-foreground-process'
|
||||
// Toolhelp denial may return an empty table; require the querying process to distinguish a readable snapshot.
|
||||
@@ -61,13 +60,6 @@ function mockWindowsRows(rows: NativeProcessRow[] = DEFAULT_WINDOWS_ROWS): void
|
||||
})
|
||||
}
|
||||
|
||||
/** `undefined` rows are how the native snapshot reports a table it cannot read. */
|
||||
function mockUnreadableWindowsTable(): void {
|
||||
getAllProcessesMock.mockImplementation((cb: (snapshot: undefined) => void) => {
|
||||
cb(undefined)
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveAgentForegroundProcess', () => {
|
||||
let platform: PropertyDescriptor | undefined
|
||||
|
||||
@@ -91,155 +83,7 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not report a suspended agent when a non-agent holds the foreground', async () => {
|
||||
// vim owns foreground (+); Ctrl-Z left Codex as a stopped background descendant.
|
||||
mockPs(
|
||||
[
|
||||
'101 100 T node /Users/dev/.nvm/versions/node/bin/codex',
|
||||
'102 100 S+ vim notes.txt'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'vim')).resolves.toBe('vim')
|
||||
})
|
||||
|
||||
it('still reports a foreground agent', async () => {
|
||||
mockPs(['101 100 S+ node /Users/dev/.nvm/versions/node/bin/codex'].join('\n'))
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex')
|
||||
})
|
||||
|
||||
// Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364).
|
||||
it('reports the outer omp wrapper, not the wrapped pi child', async () => {
|
||||
mockPs(['101 100 S+ omp', '102 101 S+ pi'].join('\n'))
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'omp')).resolves.toBe('omp')
|
||||
})
|
||||
|
||||
it('reports omp even when the wrapped pi child holds the foreground alone', async () => {
|
||||
// OMP identity must survive samples where only the wrapped Pi child owns foreground.
|
||||
mockPs(['101 100 S omp', '102 101 S+ pi'].join('\n'))
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'omp')).resolves.toBe('omp')
|
||||
})
|
||||
|
||||
it('reports bare pi when no omp wrapper is present', async () => {
|
||||
mockPs(['101 100 S+ pi'].join('\n'))
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'pi')).resolves.toBe('pi')
|
||||
})
|
||||
|
||||
it('reports the outer omp wrapper on Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'omp.exe',
|
||||
commandLine: 'omp.exe'
|
||||
},
|
||||
{
|
||||
pid: 102,
|
||||
ppid: 101,
|
||||
name: 'pi.exe',
|
||||
commandLine: 'pi.exe'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'pi.exe')).resolves.toBe('omp')
|
||||
})
|
||||
|
||||
it('keeps the Windows omp ancestor when context selects one of multiple pi descendants', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'omp.exe',
|
||||
commandLine: 'omp.exe'
|
||||
},
|
||||
{
|
||||
pid: 102,
|
||||
ppid: 101,
|
||||
name: 'pi.exe',
|
||||
commandLine: 'pi.exe --cwd C:\\repo\\orca'
|
||||
},
|
||||
{
|
||||
pid: 103,
|
||||
ppid: 100,
|
||||
name: 'pi.exe',
|
||||
commandLine: 'pi.exe --cwd C:\\repo\\other'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcess(100, 'pi.exe', { contextPaths: ['C:\\repo\\orca'] })
|
||||
).resolves.toBe('omp')
|
||||
})
|
||||
|
||||
it('treats a fresh POSIX snapshot missing the PTY root as unavailable', async () => {
|
||||
mockPs('101 999 S+ node /Users/dev/.nvm/versions/node/bin/codex')
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true })
|
||||
).resolves.toEqual({ available: false, processName: 'zsh' })
|
||||
})
|
||||
|
||||
it('treats failed POSIX scans as unavailable', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
callback(new Error('ps unavailable'), { stdout: '', stderr: '' })
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true })
|
||||
).resolves.toEqual({ available: false, processName: 'zsh' })
|
||||
await expect(resolveAgentForegroundProcessWithAvailability(100, 'zsh')).resolves.toEqual({
|
||||
available: false,
|
||||
processName: 'zsh'
|
||||
})
|
||||
await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh')
|
||||
})
|
||||
|
||||
it('confirms a quoted login shell only when its fresh PTY tree contains shells', async () => {
|
||||
mockPs(['100 99 100 100 Ss+ "/bin/zsh" -l', '101 100 101 100 S+ /bin/bash'].join('\n'))
|
||||
|
||||
await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('uses spawned-shell identity instead of a lagging foreground child label', async () => {
|
||||
mockPs(['100 99 100 100 Ss+ /bin/zsh -l'].join('\n'))
|
||||
|
||||
await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('confirms the spawned shell behind a login wrapper while prompt hooks run', async () => {
|
||||
mockPs(
|
||||
[
|
||||
'100 99 100 101 Ss /usr/bin/login -pfl developer /bin/zsh',
|
||||
'101 100 101 101 S+ -zsh',
|
||||
'102 101 101 101 S+ (zsh)',
|
||||
'103 102 101 101 S+ (sed)',
|
||||
'104 102 101 101 R+ (git)'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a foreground nested shell while the spawned shell remains suspended', async () => {
|
||||
mockPs(
|
||||
@@ -254,271 +98,12 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('rejects shell ownership while a TUI and its nested shell remain in the PTY tree', async () => {
|
||||
mockPs(
|
||||
[
|
||||
'100 99 100 101 Ss /bin/zsh -l',
|
||||
'101 100 101 101 S+ /usr/local/bin/agent-tui',
|
||||
'102 101 101 101 S+ /bin/bash -i'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('rejects shell ownership while a stopped TUI remains resumable', async () => {
|
||||
mockPs(['100 99 100 100 Ss+ /bin/zsh -l', '101 100 101 100 T agent-tui'].join('\n'))
|
||||
|
||||
await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('refuses WSL shells: wsl.exe is not a provable shell identity', async () => {
|
||||
// The WSL job holds only wsl.exe and cannot prove guest-side shell state.
|
||||
await expect(confirmShellForegroundProcess(100, 'wsl.exe')).resolves.toBe(false)
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails open when fresh shell ownership inspection is unavailable', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
callback(new Error('ps unavailable'), { stdout: '', stderr: '' })
|
||||
}
|
||||
)
|
||||
|
||||
await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('confirms a Windows shell from fresh root-only ConPTY membership', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
const readWindowsPtyJobProcessIds = vi.fn(async () => new Set([100]))
|
||||
|
||||
await expect(
|
||||
confirmShellForegroundProcess(100, 'powershell.exe', { readWindowsPtyJobProcessIds })
|
||||
).resolves.toBe(true)
|
||||
expect(getAllProcessesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects Windows shell ownership with a child or unavailable membership', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
|
||||
await expect(
|
||||
confirmShellForegroundProcess(100, 'powershell.exe', {
|
||||
readWindowsPtyJobProcessIds: async () => new Set([100, 101])
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
await expect(
|
||||
confirmShellForegroundProcess(100, 'powershell.exe', {
|
||||
readWindowsPtyJobProcessIds: async () => null
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('does not report Claude print-mode hook descendants as foreground agents', async () => {
|
||||
mockPs(
|
||||
[
|
||||
'100 99 Ss bash -i',
|
||||
'101 100 S+ claude --print --model haiku Analyze this conversation and determine next work'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash')
|
||||
})
|
||||
|
||||
it('does not report a stopped agent after the shell regains foreground', async () => {
|
||||
mockPs(
|
||||
['100 99 Ss+ bash -i', '101 100 T node /Users/dev/.nvm/versions/node/bin/codex'].join(
|
||||
'\n'
|
||||
)
|
||||
)
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash')
|
||||
})
|
||||
|
||||
it('falls back to recognized descendants when no process in the PTY tree holds foreground', async () => {
|
||||
// Detached descendants may have no foreground marker; retain agent recognition as a fallback.
|
||||
mockPs(
|
||||
['100 99 Ss bash -i', '101 100 S node /Users/dev/.nvm/versions/node/bin/codex'].join(
|
||||
'\n'
|
||||
)
|
||||
)
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex')
|
||||
})
|
||||
|
||||
it('recognizes Windows wrapper-launched agents from descendant command lines', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows()
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('codex')
|
||||
})
|
||||
|
||||
it('recognizes Windows shell-rooted agent launches from descendant command lines', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows()
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex')
|
||||
})
|
||||
|
||||
it('recognizes the native Windows Cursor launcher process tree', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'cmd.exe',
|
||||
commandLine: 'cmd.exe /c cursor-agent.cmd'
|
||||
},
|
||||
{
|
||||
pid: 102,
|
||||
ppid: 101,
|
||||
name: 'powershell.exe',
|
||||
commandLine:
|
||||
'powershell.exe -File C:\\Users\\dev\\AppData\\Local\\cursor-agent\\cursor-agent.ps1'
|
||||
},
|
||||
{
|
||||
pid: 103,
|
||||
ppid: 102,
|
||||
name: 'node.exe',
|
||||
commandLine:
|
||||
'node.exe C:\\Users\\dev\\AppData\\Local\\cursor-agent\\versions\\2026.07.09-a3815c0\\index.js'
|
||||
},
|
||||
{
|
||||
pid: 104,
|
||||
ppid: 103,
|
||||
name: 'node.exe',
|
||||
commandLine:
|
||||
'node.exe C:\\Users\\dev\\AppData\\Local\\cursor-agent\\versions\\2026.07.09-a3815c0\\index.js worker-server'
|
||||
},
|
||||
{
|
||||
pid: 105,
|
||||
ppid: 100,
|
||||
name: 'agent.exe',
|
||||
commandLine: 'C:\\Users\\dev\\.grok\\bin\\agent.exe'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('cursor-agent')
|
||||
})
|
||||
|
||||
it('recognizes Windows Git Bash shell-rooted agent launches', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'bash.exe',
|
||||
commandLine: 'C:\\Program Files\\Git\\bin\\bash.exe --login -i'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'node.exe',
|
||||
commandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'bash.exe')).resolves.toBe('codex')
|
||||
})
|
||||
|
||||
it('keeps a multiline Windows command line inside its own row', async () => {
|
||||
// Prompt text impersonates another row; keep it within its actual process command line.
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'node.exe',
|
||||
commandLine: [
|
||||
'node',
|
||||
'C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js',
|
||||
'--prompt',
|
||||
'"line one\r\nName=gemini.exe\r\nProcessId=999"'
|
||||
].join(' ')
|
||||
}
|
||||
])
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex')
|
||||
})
|
||||
|
||||
it('distinguishes unavailable Windows enumeration from a confirmed shell', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockUnreadableWindowsTable()
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe')
|
||||
).resolves.toEqual({ available: false, processName: 'powershell.exe' })
|
||||
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe(
|
||||
'powershell.exe'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats an observed Windows shell with no children as authoritative', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe')
|
||||
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
|
||||
})
|
||||
|
||||
it('does not restore a recognized fallback that disappeared before confirmation', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcessWithAvailability(100, 'droid', {
|
||||
fresh: true,
|
||||
forceProcessScan: true
|
||||
})
|
||||
).resolves.toEqual({ available: true, processName: null })
|
||||
})
|
||||
|
||||
it('reports a foreign anchor when its pid now runs an unrecognized command', async () => {
|
||||
// Job membership cannot preserve an agent identity contradicted by immutable command-line evidence.
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
|
||||
{ pid: 999, ppid: 100, name: 'node.exe', commandLine: 'node server.js' }
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
|
||||
anchorProcessId: 999
|
||||
})
|
||||
).resolves.toEqual({
|
||||
available: true,
|
||||
processName: 'powershell.exe',
|
||||
anchorPidForeign: true
|
||||
})
|
||||
})
|
||||
|
||||
it('detects a foreign anchor even when the squatter is orphaned out of the descendant walk', async () => {
|
||||
// PID reuse can leave the squatter outside the shell ancestry while retaining job membership.
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
@@ -557,21 +142,6 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not flag an anchor whose row still recognizes as the cached agent', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
|
||||
{ pid: 999, ppid: 500, name: 'node.exe', commandLine: 'node C:\\npm\\claude' }
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
|
||||
anchorProcessId: 999,
|
||||
anchorProcessName: 'claude'
|
||||
})
|
||||
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
|
||||
})
|
||||
|
||||
it('treats a query-denied anchor row as inconclusive, never foreign', async () => {
|
||||
// A denied query yields command === name; the agent may just be unreadable.
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
@@ -587,81 +157,6 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
|
||||
})
|
||||
|
||||
it('treats a Windows snapshot missing the requested shell as unavailable', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 200,
|
||||
ppid: 99,
|
||||
name: 'unrelated.exe',
|
||||
commandLine: 'unrelated.exe'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe')
|
||||
).resolves.toEqual({ available: false, processName: 'powershell.exe' })
|
||||
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe(
|
||||
'powershell.exe'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not use unrelated Windows agent descendants for wrapper fallbacks', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'node.exe',
|
||||
commandLine: 'node C:\\repo\\server.js'
|
||||
},
|
||||
{
|
||||
pid: 102,
|
||||
ppid: 100,
|
||||
name: 'codex.exe',
|
||||
commandLine: 'codex'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('node.exe')
|
||||
})
|
||||
|
||||
it('fails closed for ambiguous Windows shell-rooted agent descendants', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'node.exe',
|
||||
commandLine:
|
||||
'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js'
|
||||
},
|
||||
{
|
||||
pid: 102,
|
||||
ppid: 100,
|
||||
name: 'node.exe',
|
||||
commandLine:
|
||||
'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe(
|
||||
'powershell.exe'
|
||||
)
|
||||
})
|
||||
|
||||
it('filters detached agents before resolving an otherwise ambiguous ConPTY tree', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
@@ -719,163 +214,6 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
expect(resolution.processName).toBe('powershell.exe')
|
||||
})
|
||||
|
||||
it('recognizes a Windows shell-rooted agent when only one candidate matches the worktree path', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'node.exe',
|
||||
commandLine:
|
||||
'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js --cwd C:\\repo\\orca'
|
||||
},
|
||||
{
|
||||
pid: 102,
|
||||
ppid: 100,
|
||||
name: 'node.exe',
|
||||
commandLine:
|
||||
'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs --cwd C:\\repo\\other'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcess(100, 'powershell.exe', {
|
||||
contextPaths: ['C:\\repo\\orca']
|
||||
})
|
||||
).resolves.toBe('codex')
|
||||
})
|
||||
|
||||
it('recognizes the deepest Windows shell-rooted agent when candidates share one lineage', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'codex.exe',
|
||||
commandLine: 'codex --cwd C:\\repo\\orca'
|
||||
},
|
||||
{
|
||||
pid: 102,
|
||||
ppid: 101,
|
||||
name: 'gemini.exe',
|
||||
commandLine: 'gemini --cwd C:\\repo\\orca'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcess(100, 'powershell.exe', {
|
||||
contextPaths: ['C:\\repo\\orca']
|
||||
})
|
||||
).resolves.toBe('gemini')
|
||||
})
|
||||
|
||||
it('fails closed for sibling Windows agents that both match the same worktree path', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'codex.exe',
|
||||
commandLine: 'codex --cwd C:\\repo\\orca'
|
||||
},
|
||||
{
|
||||
pid: 102,
|
||||
ppid: 100,
|
||||
name: 'gemini.exe',
|
||||
commandLine: 'gemini --cwd C:\\repo\\orca'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcess(100, 'powershell.exe', {
|
||||
contextPaths: ['C:\\repo\\orca']
|
||||
})
|
||||
).resolves.toBe('powershell.exe')
|
||||
})
|
||||
|
||||
it('fails closed when Windows has multiple matching wrapper descendants', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'node.exe',
|
||||
commandLine: 'node C:\\repo\\server.js'
|
||||
},
|
||||
{
|
||||
pid: 102,
|
||||
ppid: 100,
|
||||
name: 'node.exe',
|
||||
commandLine:
|
||||
'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('node.exe')
|
||||
})
|
||||
|
||||
it('does not enrich Windows foregrounds that are not interpreter wrappers', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
|
||||
await expect(resolveAgentForegroundProcess(100, 'vim.exe')).resolves.toBe('vim.exe')
|
||||
expect(getAllProcessesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('authorizes a fresh Windows agent only when it still belongs to the ConPTY', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
ppid: 100,
|
||||
name: 'droid.exe',
|
||||
commandLine: 'droid'
|
||||
}
|
||||
])
|
||||
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101, 999]))
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
|
||||
fresh: true,
|
||||
readWindowsConsoleAttachedProcessIds
|
||||
})
|
||||
).resolves.toEqual({
|
||||
available: true,
|
||||
processName: 'droid',
|
||||
processId: 101
|
||||
})
|
||||
expect(readWindowsConsoleAttachedProcessIds).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('excludes a detached Windows Droid descendant from byte authority', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
@@ -900,25 +238,4 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
})
|
||||
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
|
||||
})
|
||||
|
||||
it('does not fork the ConPTY membership helper when no Windows agent is inferred', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
mockWindowsRows([
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 99,
|
||||
name: 'powershell.exe',
|
||||
commandLine: 'powershell.exe'
|
||||
}
|
||||
])
|
||||
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 999]))
|
||||
|
||||
await expect(
|
||||
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
|
||||
fresh: true,
|
||||
readWindowsConsoleAttachedProcessIds
|
||||
})
|
||||
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
|
||||
expect(readWindowsConsoleAttachedProcessIds).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { git } = vi.hoisted(() => ({
|
||||
git: {
|
||||
getStatus: vi.fn(),
|
||||
getSubmoduleStatus: vi.fn(),
|
||||
checkIgnoredPaths: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
commitChanges: vi.fn(),
|
||||
getStagedCommitContext: vi.fn(),
|
||||
getDiff: vi.fn(),
|
||||
stageFile: vi.fn(),
|
||||
unstageFile: vi.fn(),
|
||||
bulkStageFiles: vi.fn(),
|
||||
stageWorktreeChanges: vi.fn(),
|
||||
bulkUnstageFiles: vi.fn(),
|
||||
discardChanges: vi.fn(),
|
||||
bulkDiscardChanges: vi.fn(),
|
||||
detectConflictOperation: vi.fn(),
|
||||
abortMerge: vi.fn(),
|
||||
abortRebase: vi.fn(),
|
||||
checkoutBranch: vi.fn(),
|
||||
listLocalBranches: vi.fn(),
|
||||
getBranchCompare: vi.fn(),
|
||||
getCommitCompare: vi.fn(),
|
||||
getCommitDiff: vi.fn(),
|
||||
getUpstreamStatus: vi.fn(),
|
||||
gitPush: vi.fn(),
|
||||
gitPull: vi.fn(),
|
||||
gitFastForward: vi.fn(),
|
||||
gitPullRebaseFromBase: vi.fn(),
|
||||
gitFetch: vi.fn(),
|
||||
gitSyncForkDefaultBranch: vi.fn(),
|
||||
listWorktrees: vi.fn(),
|
||||
isGitRepo: vi.fn(),
|
||||
gitExecFileAsync: vi.fn(),
|
||||
awaitWindowsHostGitEnvironmentReady: vi.fn(),
|
||||
getRemoteFileUrl: vi.fn(),
|
||||
getRemoteCommitUrl: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../git/source-control/status-read', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getStatus: git.getStatus
|
||||
}))
|
||||
|
||||
vi.mock('../git/source-control/submodule-status', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getSubmoduleStatus: git.getSubmoduleStatus
|
||||
}))
|
||||
|
||||
vi.mock('../git/source-control/commit-changes', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
commitChanges: git.commitChanges
|
||||
}))
|
||||
|
||||
vi.mock('../git/source-control/staged-commit-context', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getStagedCommitContext: git.getStagedCommitContext
|
||||
}))
|
||||
|
||||
vi.mock('../git/source-control/file-diff', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getDiff: git.getDiff
|
||||
}))
|
||||
|
||||
vi.mock('../git/source-control/staging', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
stageFile: git.stageFile,
|
||||
unstageFile: git.unstageFile,
|
||||
bulkStageFiles: git.bulkStageFiles,
|
||||
stageWorktreeChanges: git.stageWorktreeChanges,
|
||||
bulkUnstageFiles: git.bulkUnstageFiles
|
||||
}))
|
||||
|
||||
vi.mock('../git/source-control/discard-changes', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
discardChanges: git.discardChanges,
|
||||
bulkDiscardChanges: git.bulkDiscardChanges
|
||||
}))
|
||||
|
||||
vi.mock('../git/source-control/git-conflict-operation', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
detectConflictOperation: git.detectConflictOperation,
|
||||
abortMerge: git.abortMerge,
|
||||
abortRebase: git.abortRebase
|
||||
}))
|
||||
|
||||
vi.mock('../git/source-control/branch-compare', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getBranchCompare: git.getBranchCompare
|
||||
}))
|
||||
|
||||
vi.mock('../git/source-control/commit-compare', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getCommitCompare: git.getCommitCompare
|
||||
}))
|
||||
|
||||
vi.mock('../git/source-control/commit-diff', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getCommitDiff: git.getCommitDiff
|
||||
}))
|
||||
vi.mock('../git/source-control/check-ignored-paths', () => ({
|
||||
checkIgnoredPaths: git.checkIgnoredPaths
|
||||
}))
|
||||
vi.mock('../git/checkout', () => ({
|
||||
checkoutBranch: git.checkoutBranch,
|
||||
listLocalBranches: git.listLocalBranches
|
||||
}))
|
||||
vi.mock('../git/history', () => ({ getHistory: git.getHistory }))
|
||||
vi.mock('../git/remote', () => git)
|
||||
vi.mock('../git/fork-sync', () => ({ gitSyncForkDefaultBranch: git.gitSyncForkDefaultBranch }))
|
||||
vi.mock('../git/source-control/upstream', () => ({ getUpstreamStatus: git.getUpstreamStatus }))
|
||||
vi.mock('../git/worktrees/catalog/worktree-scan-cache', () => ({
|
||||
listWorktrees: git.listWorktrees
|
||||
}))
|
||||
vi.mock('../git/repo', () => git)
|
||||
vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
gitExecFileAsync: git.gitExecFileAsync
|
||||
}))
|
||||
|
||||
vi.mock('../git/command-runner/windows-host-git-environment', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
awaitWindowsHostGitEnvironmentReady: git.awaitWindowsHostGitEnvironmentReady
|
||||
}))
|
||||
|
||||
import { createLocalGitProvider, type LocalGitProvider } from './local-git-provider'
|
||||
|
||||
const WT = '/repo/wt'
|
||||
const pushTarget = { remoteName: 'origin', branchName: 'feature' }
|
||||
const interactive = { admissionTier: 'interactive' }
|
||||
|
||||
// [provider call, free function, expected free-function args] — mirrors the runtime local arms.
|
||||
type ParityCase = [string, (p: LocalGitProvider) => unknown, keyof typeof git, unknown[]]
|
||||
const cases: ParityCase[] = [
|
||||
['getStatus', (p) => p.getStatus(WT), 'getStatus', [WT, { admissionTier: 'status' }]],
|
||||
[
|
||||
'getStatus with options',
|
||||
(p) => p.getStatus(WT, { includeIgnored: true, admissionTier: 'background' }),
|
||||
'getStatus',
|
||||
[WT, { includeIgnored: true, admissionTier: 'background' }]
|
||||
],
|
||||
[
|
||||
'getSubmoduleStatus staged',
|
||||
(p) => p.getSubmoduleStatus(WT, 'sub', 'staged'),
|
||||
'getSubmoduleStatus',
|
||||
[WT, 'sub', { ...interactive, staged: true }]
|
||||
],
|
||||
[
|
||||
'getSubmoduleStatus default',
|
||||
(p) => p.getSubmoduleStatus(WT, 'sub'),
|
||||
'getSubmoduleStatus',
|
||||
[WT, 'sub', interactive]
|
||||
],
|
||||
[
|
||||
'checkIgnoredPaths',
|
||||
(p) => p.checkIgnoredPaths(WT, ['a']),
|
||||
'checkIgnoredPaths',
|
||||
[WT, ['a'], interactive]
|
||||
],
|
||||
[
|
||||
'getHistory',
|
||||
(p) => p.getHistory(WT, { limit: 5 }),
|
||||
'getHistory',
|
||||
[WT, { limit: 5, ...interactive }]
|
||||
],
|
||||
['commit', (p) => p.commit(WT, 'msg'), 'commitChanges', [WT, 'msg', interactive]],
|
||||
[
|
||||
'getStagedCommitContext',
|
||||
(p) => p.getStagedCommitContext(WT),
|
||||
'getStagedCommitContext',
|
||||
[WT, interactive]
|
||||
],
|
||||
['getDiff', (p) => p.getDiff(WT, 'a', true, true), 'getDiff', [WT, 'a', true, true, interactive]],
|
||||
['stageFile', (p) => p.stageFile(WT, 'a'), 'stageFile', [WT, 'a', interactive]],
|
||||
['unstageFile', (p) => p.unstageFile(WT, 'a'), 'unstageFile', [WT, 'a', interactive]],
|
||||
[
|
||||
'bulkStageFiles',
|
||||
(p) => p.bulkStageFiles(WT, ['a']),
|
||||
'bulkStageFiles',
|
||||
[WT, ['a'], interactive]
|
||||
],
|
||||
[
|
||||
'bulkStageFiles scoped',
|
||||
(p) => p.bulkStageFiles(WT, [], 'all'),
|
||||
'stageWorktreeChanges',
|
||||
[WT, 'all', { ...interactive, sharedLinkPaths: [] }]
|
||||
],
|
||||
[
|
||||
'bulkUnstageFiles',
|
||||
(p) => p.bulkUnstageFiles(WT, ['a']),
|
||||
'bulkUnstageFiles',
|
||||
[WT, ['a'], interactive]
|
||||
],
|
||||
['discardChanges', (p) => p.discardChanges(WT, 'a'), 'discardChanges', [WT, 'a', interactive]],
|
||||
[
|
||||
'bulkDiscardChanges',
|
||||
(p) => p.bulkDiscardChanges(WT, ['a']),
|
||||
'bulkDiscardChanges',
|
||||
[WT, ['a'], interactive]
|
||||
],
|
||||
[
|
||||
'detectConflictOperation',
|
||||
(p) => p.detectConflictOperation(WT),
|
||||
'detectConflictOperation',
|
||||
[WT, {}]
|
||||
],
|
||||
['abortMerge', (p) => p.abortMerge(WT), 'abortMerge', [WT, interactive]],
|
||||
['abortRebase', (p) => p.abortRebase(WT), 'abortRebase', [WT, interactive]],
|
||||
['checkoutBranch', (p) => p.checkoutBranch(WT, 'b'), 'checkoutBranch', [WT, 'b', interactive]],
|
||||
['listLocalBranches', (p) => p.listLocalBranches(WT), 'listLocalBranches', [WT, {}]],
|
||||
[
|
||||
'getBranchCompare default tier',
|
||||
(p) => p.getBranchCompare(WT, 'main'),
|
||||
'getBranchCompare',
|
||||
[WT, 'main', interactive]
|
||||
],
|
||||
[
|
||||
'getBranchCompare background',
|
||||
(p) => p.getBranchCompare(WT, 'main', { admissionTier: 'background' }),
|
||||
'getBranchCompare',
|
||||
[WT, 'main', { admissionTier: 'background' }]
|
||||
],
|
||||
[
|
||||
'getCommitCompare',
|
||||
(p) => p.getCommitCompare(WT, 'abc'),
|
||||
'getCommitCompare',
|
||||
[WT, 'abc', interactive]
|
||||
],
|
||||
[
|
||||
'getCommitDiff',
|
||||
(p) => p.getCommitDiff(WT, { commitOid: 'abc', filePath: 'a' }),
|
||||
'getCommitDiff',
|
||||
[WT, { commitOid: 'abc', filePath: 'a' }, interactive]
|
||||
],
|
||||
[
|
||||
'getUpstreamStatus',
|
||||
(p) => p.getUpstreamStatus(WT, pushTarget),
|
||||
'getUpstreamStatus',
|
||||
[WT, pushTarget, {}]
|
||||
],
|
||||
[
|
||||
'pushBranch',
|
||||
(p) => p.pushBranch(WT, true, pushTarget, { forceWithLease: true }),
|
||||
'gitPush',
|
||||
[WT, true, pushTarget, { forceWithLease: true, ...interactive }]
|
||||
],
|
||||
[
|
||||
'pushBranch defaults',
|
||||
(p) => p.pushBranch(WT),
|
||||
'gitPush',
|
||||
[WT, false, undefined, { forceWithLease: false, ...interactive }]
|
||||
],
|
||||
['pullBranch', (p) => p.pullBranch(WT, pushTarget), 'gitPull', [WT, pushTarget, interactive]],
|
||||
[
|
||||
'fastForwardBranch',
|
||||
(p) => p.fastForwardBranch(WT, pushTarget),
|
||||
'gitFastForward',
|
||||
[WT, pushTarget, interactive]
|
||||
],
|
||||
[
|
||||
'rebaseFromBase',
|
||||
(p) => p.rebaseFromBase(WT, 'main'),
|
||||
'gitPullRebaseFromBase',
|
||||
[WT, 'main', interactive]
|
||||
],
|
||||
['fetchRemote', (p) => p.fetchRemote(WT), 'gitFetch', [WT, undefined, interactive]],
|
||||
[
|
||||
'syncForkDefaultBranch',
|
||||
(p) => p.syncForkDefaultBranch(WT, { owner: 'acme', repo: 'orca' }),
|
||||
'gitSyncForkDefaultBranch',
|
||||
[WT, { owner: 'acme', repo: 'orca' }, interactive]
|
||||
],
|
||||
['listWorktrees', (p) => p.listWorktrees('/repo'), 'listWorktrees', ['/repo', {}]],
|
||||
[
|
||||
'exec',
|
||||
(p) => p.exec(['status'], WT, { timeoutMs: 5 }),
|
||||
'gitExecFileAsync',
|
||||
[['status'], { cwd: WT, timeout: 5 }]
|
||||
],
|
||||
[
|
||||
'getRemoteFileUrl',
|
||||
(p) => p.getRemoteFileUrl(WT, 'a', 3, 8),
|
||||
'getRemoteFileUrl',
|
||||
[WT, 'a', 3, 8, {}]
|
||||
],
|
||||
['getRemoteCommitUrl', (p) => p.getRemoteCommitUrl(WT, 'abc'), 'getRemoteCommitUrl', [WT, 'abc']]
|
||||
]
|
||||
|
||||
describe('createLocalGitProvider', () => {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(git)) {
|
||||
fn.mockReset()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(cases)('%s calls the local free function as the runtime local arm does', async (...c) => {
|
||||
const [, call, fnName, expected] = c
|
||||
await call(createLocalGitProvider())
|
||||
expect(git[fnName]).toHaveBeenCalledTimes(1)
|
||||
expect(git[fnName]).toHaveBeenCalledWith(...expected)
|
||||
})
|
||||
|
||||
it.each(cases)('%s keeps the WSL distro on every call', async (...c) => {
|
||||
const [, call, fnName] = c
|
||||
await call(createLocalGitProvider({ wslDistro: 'Ubuntu' }))
|
||||
const args = git[fnName].mock.calls[0]
|
||||
const options = args.at(-1)
|
||||
if (fnName === 'getRemoteCommitUrl') {
|
||||
// URL reads require Windows-hosted Git readiness, matching local execution.
|
||||
expect(git.awaitWindowsHostGitEnvironmentReady).toHaveBeenCalledWith({ cwd: WT })
|
||||
return
|
||||
}
|
||||
expect(options).toMatchObject({ wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('applies shared link paths to status and scoped stage-all only', async () => {
|
||||
const getSharedLinkPaths = vi.fn(() => ['node_modules'])
|
||||
const provider = createLocalGitProvider({ getSharedLinkPaths })
|
||||
await provider.getDiff(WT, 'a', false)
|
||||
expect(getSharedLinkPaths).not.toHaveBeenCalled()
|
||||
await provider.getStatus(WT)
|
||||
await provider.bulkStageFiles(WT, [], 'all')
|
||||
await provider.bulkStageFiles(WT, ['a'])
|
||||
expect(git.getStatus).toHaveBeenCalledWith(WT, {
|
||||
admissionTier: 'status',
|
||||
sharedLinkPaths: ['node_modules']
|
||||
})
|
||||
expect(git.stageWorktreeChanges).toHaveBeenCalledWith(WT, 'all', {
|
||||
...interactive,
|
||||
sharedLinkPaths: ['node_modules']
|
||||
})
|
||||
expect(git.bulkStageFiles).toHaveBeenCalledWith(WT, ['a'], interactive)
|
||||
})
|
||||
|
||||
it('lets the per-call status tier win over a caller-wide default tier', async () => {
|
||||
await createLocalGitProvider({ admissionTier: 'background' }).getStatus(WT)
|
||||
expect(git.getStatus).toHaveBeenCalledWith(WT, { admissionTier: 'status' })
|
||||
})
|
||||
|
||||
it('passes errors from the free function through unchanged', async () => {
|
||||
const error = new Error('fatal: not a git repository')
|
||||
git.getStatus.mockRejectedValueOnce(error)
|
||||
await expect(createLocalGitProvider().getStatus('/not-git')).rejects.toBe(error)
|
||||
})
|
||||
})
|
||||
@@ -31,8 +31,6 @@ import { isRetiredPtyMaster } from '../../pty/process-retirement/node-pty-master
|
||||
import { LocalPtyProvider } from './local-pty-provider'
|
||||
import { ptyIncarnations, ptyProcesses, ptyShellPath } from './local-pty-provider-state'
|
||||
import { inspectPtyProviderProcess } from '../pty-process-inspection'
|
||||
import { readLaunchedAgentForeground } from '../../runtime/terminal/wait/launched-agent-foreground'
|
||||
import type { TerminalForegroundSample } from '../../../shared/terminal/pty/lifecycle/terminal-foreground-observation'
|
||||
|
||||
// Match the recorded basename so only the retired descriptor distinguishes the pane from idle.
|
||||
const POSIX_SHELL = 'sh'
|
||||
@@ -106,67 +104,6 @@ const describeOnPosix = process.platform === 'win32' ? describe.skip : describe
|
||||
describe('inspectPtyProviderProcess child-process evidence', () => {
|
||||
const provider = new LocalPtyProvider()
|
||||
|
||||
it('uses the owned Windows job without a static-name marker, preserving uncertain and replaced panes', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
registerPane('pty-win', 'powershell.exe', 'powershell.exe')
|
||||
const inspect = () =>
|
||||
inspectPtyProviderProcess(provider, 'pty-win', {
|
||||
expectedIncarnationId: 'incarnation:pty-win'
|
||||
})
|
||||
jobMembers.mockReturnValue(new Set([4242]))
|
||||
await expect(inspect()).resolves.toMatchObject({
|
||||
childProcessEvidence: 'no-children',
|
||||
hasChildProcesses: false
|
||||
})
|
||||
jobMembers.mockReturnValue(new Set([4242, 4243]))
|
||||
await expect(inspect()).resolves.toMatchObject({
|
||||
childProcessEvidence: 'children',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
jobMembers.mockReturnValue(null)
|
||||
await expect(inspect()).resolves.toMatchObject({
|
||||
childProcessEvidence: 'unverifiable',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
jobMembers.mockImplementationOnce(() => {
|
||||
registerPane('pty-win', 'powershell.exe', 'powershell.exe')
|
||||
return new Set([4242])
|
||||
})
|
||||
await expect(inspect()).resolves.toMatchObject({
|
||||
childProcessEvidence: 'unverifiable',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
})
|
||||
|
||||
it('carries unverifiable evidence when the child read fails after foreground inspection', async () => {
|
||||
let reads = 0
|
||||
registerPane(
|
||||
'pty-closing',
|
||||
() => {
|
||||
reads += 1
|
||||
if (reads > 1) {
|
||||
throw new Error('EBADF: bad file descriptor')
|
||||
}
|
||||
return '/bin/zsh'
|
||||
},
|
||||
'/bin/zsh'
|
||||
)
|
||||
await expect(inspectPtyProviderProcess(provider, 'pty-closing')).resolves.toEqual({
|
||||
foregroundProcess: '/bin/zsh',
|
||||
hasChildProcesses: true,
|
||||
childProcessEvidence: 'unverifiable'
|
||||
})
|
||||
})
|
||||
|
||||
it('samples child evidence after foreground inspection', async () => {
|
||||
let reads = 0
|
||||
registerPane('pty-became-busy', () => (reads++ === 0 ? '/bin/zsh' : 'vim'), '/bin/zsh')
|
||||
|
||||
const inspection = await inspectPtyProviderProcess(provider, 'pty-became-busy')
|
||||
expect(inspection.hasChildProcesses).toBe(true)
|
||||
expect(inspection.childProcessEvidence).toBe('children')
|
||||
})
|
||||
|
||||
it('refuses to pair one panes foreground with its replacements children', async () => {
|
||||
registerPane('pty-swapped', '/bin/zsh', '/bin/zsh')
|
||||
resolveForegroundMock.mockImplementation(async () => {
|
||||
@@ -183,47 +120,6 @@ describe('inspectPtyProviderProcess child-process evidence', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('fresh foreground observation', () => {
|
||||
it.each([
|
||||
['non-shell', 'claude', 'agent'],
|
||||
['shell', '-zsh', 'shell']
|
||||
] as const)(
|
||||
'keeps a %s observation valid when the wall clock steps back during its read',
|
||||
async (foregroundKind, processName, expected) => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(1_000)
|
||||
registerPane('pty-clock-step', '/bin/zsh', '/bin/zsh')
|
||||
observeForegroundMock.mockImplementationOnce(async (): Promise<TerminalForegroundSample> => {
|
||||
vi.setSystemTime(995)
|
||||
return { verdict: 'observed', source: 'pty-master', foregroundKind, processName }
|
||||
})
|
||||
const provider = new LocalPtyProvider()
|
||||
const inspectProcess = vi.fn((id: string, options?: { foregroundOnly?: boolean }) =>
|
||||
provider.inspectProcess(id, options)
|
||||
)
|
||||
try {
|
||||
expect(
|
||||
await readLaunchedAgentForeground(
|
||||
{
|
||||
supportsForegroundObservation: () => true,
|
||||
inspectProcess,
|
||||
getForegroundProcess: async () => null
|
||||
},
|
||||
{ remote: false, windows: false },
|
||||
'pty-clock-step',
|
||||
'claude'
|
||||
)
|
||||
).toBe(expected)
|
||||
await expect(inspectProcess.mock.results[0].value).resolves.toMatchObject({
|
||||
foregroundObservation: { verdict: 'observed', capturedAgeMs: 0 }
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describeOnPosix('inspectPtyProviderProcess on a retired master', () => {
|
||||
const provider = new LocalPtyProvider()
|
||||
|
||||
|
||||
@@ -46,152 +46,8 @@ vi.mock('../macos-tcc-login-shell', () => ({
|
||||
wrapShellSpawnForMacosTccAttribution: wrapSpawnMock,
|
||||
hostReportsChildExitStatus: (file: string) => file !== '/usr/bin/login'
|
||||
}))
|
||||
|
||||
import { resolveUnixShellPath, spawnShellWithFallback } from './local-pty-executable-spawn'
|
||||
import {
|
||||
validateWorkingDirectory,
|
||||
_resetWorkingDirectoryValidationStateForTest
|
||||
} from '../working-directory-validation'
|
||||
import { getFishXdgDataDirsLaunchEnv } from '../../pty/shell-startup/fish-xdg-data-dirs-handoff'
|
||||
|
||||
const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
const NATIVE_DIR = 'C:\\Users\\jin\\repo'
|
||||
|
||||
describe('validateWorkingDirectory', () => {
|
||||
beforeEach(() => {
|
||||
existsSyncMock.mockReset()
|
||||
statSyncMock.mockReset()
|
||||
statMock.mockReset()
|
||||
wslUncDirectoryExistsAsyncMock.mockReset()
|
||||
_resetWorkingDirectoryValidationStateForTest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('accepts a WSL UNC worktree that exists inside the distro even when fs.statSync would fail', async () => {
|
||||
// Windows 9P stat can falsely report missing WSL worktrees; distro-side existence wins.
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(true)
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
|
||||
await expect(validateWorkingDirectory(WSL_UNC_DIR)).resolves.toBeUndefined()
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(WSL_UNC_DIR)
|
||||
expect(existsSyncMock).not.toHaveBeenCalled()
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns to the event loop while the distro probe is still running', async () => {
|
||||
const probe = Promise.withResolvers<boolean | null>()
|
||||
wslUncDirectoryExistsAsyncMock.mockReturnValue(probe.promise)
|
||||
let settled = false
|
||||
|
||||
const validation = validateWorkingDirectory(WSL_UNC_DIR).finally(() => {
|
||||
settled = true
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(settled).toBe(false)
|
||||
|
||||
probe.resolve(true)
|
||||
await validation
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a WSL UNC worktree that does not exist inside the distro', async () => {
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
|
||||
|
||||
await expect(validateWorkingDirectory(WSL_UNC_DIR)).rejects.toThrow(/does not exist/)
|
||||
expect(existsSyncMock).not.toHaveBeenCalled()
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the fs check when the distro answer is inconclusive', async () => {
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
|
||||
statMock.mockResolvedValue(dirStats(true))
|
||||
|
||||
await expect(validateWorkingDirectory(WSL_UNC_DIR)).resolves.toBeUndefined()
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(WSL_UNC_DIR)
|
||||
expect(statMock).toHaveBeenCalledWith(WSL_UNC_DIR)
|
||||
expect(existsSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates native Windows paths via fs without consulting the distro', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
statSyncMock.mockReturnValue(dirStats(true))
|
||||
|
||||
await expect(validateWorkingDirectory(NATIVE_DIR)).resolves.toBeUndefined()
|
||||
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
|
||||
expect(existsSyncMock).toHaveBeenCalledWith(NATIVE_DIR)
|
||||
})
|
||||
|
||||
it('rejects a missing native Windows path', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const previousVersion = process.env.ORCA_APP_VERSION
|
||||
process.env.ORCA_APP_VERSION = '1.4.178-test'
|
||||
|
||||
try {
|
||||
await expect(validateWorkingDirectory(NATIVE_DIR)).rejects.toThrow(
|
||||
/does not exist.*orca: 1\.4\.178-test/
|
||||
)
|
||||
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
if (previousVersion === undefined) {
|
||||
delete process.env.ORCA_APP_VERSION
|
||||
} else {
|
||||
process.env.ORCA_APP_VERSION = previousVersion
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a native Windows path that exists but is not a directory', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
statSyncMock.mockReturnValue(dirStats(false))
|
||||
|
||||
await expect(validateWorkingDirectory(NATIVE_DIR)).rejects.toThrow(/is not a directory/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveUnixShellPath', () => {
|
||||
beforeEach(() => {
|
||||
existsSyncMock.mockReset()
|
||||
accessSyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('preserves non-absolute shells for execvp PATH and cwd resolution', () => {
|
||||
expect(resolveUnixShellPath('bash')).toBe('bash')
|
||||
expect(resolveUnixShellPath('./bin/custom-shell')).toBe('./bin/custom-shell')
|
||||
expect(existsSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects an executable fallback before node-pty can fork a missing shell', () => {
|
||||
existsSyncMock.mockImplementation((path) => path === '/bin/sh')
|
||||
accessSyncMock.mockReturnValue(undefined)
|
||||
|
||||
expect(resolveUnixShellPath('/missing/zsh')).toBe('/bin/sh')
|
||||
expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual([
|
||||
'/missing/zsh',
|
||||
'/bin/zsh',
|
||||
'/bin/bash',
|
||||
'/bin/sh'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports every attempted shell when no executable candidate exists', () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
|
||||
expect(() => resolveUnixShellPath('/missing/zsh')).toThrow(
|
||||
'No executable Unix shell found (tried: /missing/zsh, /bin/zsh, /bin/bash, /bin/sh)'
|
||||
)
|
||||
})
|
||||
|
||||
it('dedupes a preferred shell that is already a fallback candidate', () => {
|
||||
existsSyncMock.mockImplementation((path) => path === '/bin/sh')
|
||||
accessSyncMock.mockReturnValue(undefined)
|
||||
|
||||
expect(resolveUnixShellPath('/bin/sh')).toBe('/bin/sh')
|
||||
expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual(['/bin/sh'])
|
||||
})
|
||||
})
|
||||
import { spawnShellWithFallback } from './local-pty-executable-spawn'
|
||||
import { _resetWorkingDirectoryValidationStateForTest } from '../working-directory-validation'
|
||||
|
||||
describe('spawnShellWithFallback macOS TCC login wrapping', () => {
|
||||
let origPlatform: PropertyDescriptor | undefined
|
||||
@@ -215,137 +71,6 @@ describe('spawnShellWithFallback macOS TCC login wrapping', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('spawns the primary shell through the login wrapper', () => {
|
||||
const ptySpawn = vi.fn().mockReturnValue({ pid: 1 })
|
||||
|
||||
const result = spawnShellWithFallback({
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/work',
|
||||
env: {},
|
||||
ptySpawn: ptySpawn as never
|
||||
})
|
||||
|
||||
expect(wrapSpawnMock).toHaveBeenCalledWith('/bin/zsh', ['-l'], expect.any(Object))
|
||||
expect(ptySpawn).toHaveBeenCalledWith(
|
||||
'/wrapped/login',
|
||||
['wrapped', '/bin/zsh', '-l'],
|
||||
expect.objectContaining({ cwd: '/work', cols: 80, rows: 24 })
|
||||
)
|
||||
// Attribution wrapping must retain the actual shell path for identity checks.
|
||||
expect(result.shellPath).toBe('/bin/zsh')
|
||||
})
|
||||
|
||||
it('wraps fallback shells too when the primary fails to spawn', () => {
|
||||
const ptySpawn = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('primary boom')
|
||||
})
|
||||
.mockReturnValue({ pid: 2 })
|
||||
|
||||
const result = spawnShellWithFallback({
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/work',
|
||||
env: {},
|
||||
ptySpawn: ptySpawn as never
|
||||
})
|
||||
|
||||
// A login-wrapped fallback must receive corrected SHELL so login(1) preserves it.
|
||||
expect(wrapSpawnMock).toHaveBeenLastCalledWith(
|
||||
'/bin/bash',
|
||||
['-l'],
|
||||
expect.objectContaining({ SHELL: '/bin/bash' })
|
||||
)
|
||||
expect(ptySpawn).toHaveBeenLastCalledWith(
|
||||
'/wrapped/login',
|
||||
['wrapped', '/bin/bash', '-l'],
|
||||
expect.objectContaining({ cwd: '/work' })
|
||||
)
|
||||
expect(result.shellPath).toBe('/bin/bash')
|
||||
})
|
||||
|
||||
it('drops the primary shell’s launch env when an unwrapped fallback takes over', () => {
|
||||
// Unwrapped fallbacks must not inherit ZDOTDIR/features that would wrap an unrelated nested shell.
|
||||
const zshLaunchEnv = {
|
||||
ZDOTDIR: '/userdata/shell-ready/zsh',
|
||||
ORCA_ORIG_ZDOTDIR: '/home/jin',
|
||||
ORCA_SHELL_FEATURES: 'history'
|
||||
}
|
||||
const env: Record<string, string> = { HOME: '/home/jin', ...zshLaunchEnv }
|
||||
const ptySpawn = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('primary boom')
|
||||
})
|
||||
.mockReturnValue({ pid: 3 })
|
||||
|
||||
spawnShellWithFallback({
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/work',
|
||||
env,
|
||||
ptySpawn: ptySpawn as never,
|
||||
preLaunchEnv: {
|
||||
ZDOTDIR: undefined,
|
||||
ORCA_ORIG_ZDOTDIR: undefined,
|
||||
ORCA_SHELL_FEATURES: undefined
|
||||
},
|
||||
getShellReadyConfig: (shell) =>
|
||||
shell === '/bin/zsh' ? { args: ['-l'], env: zshLaunchEnv } : { args: null, env: {} }
|
||||
})
|
||||
|
||||
expect(env.ORCA_SHELL_FEATURES).toBeUndefined()
|
||||
expect(env.ZDOTDIR).toBeUndefined()
|
||||
expect(env.ORCA_ORIG_ZDOTDIR).toBeUndefined()
|
||||
expect(env.HOME).toBe('/home/jin')
|
||||
})
|
||||
|
||||
it.each<[string, Record<string, string>, Record<string, string>]>([
|
||||
[
|
||||
'/opt/homebrew/bin/fish',
|
||||
{ XDG_DATA_DIRS: '/opt/a:/opt/b' },
|
||||
getFishXdgDataDirsLaunchEnv('/userdata/wrappers', '/opt/a:/opt/b')
|
||||
],
|
||||
[
|
||||
'/bin/zsh',
|
||||
{ ZDOTDIR: '/home/jin/.zsh' },
|
||||
{ ZDOTDIR: '/userdata/shell-ready/zsh', ORCA_SHELL_FEATURES: 'history' }
|
||||
]
|
||||
])('hands back the user’s own env when %s falls back', (shellPath, userEnv, launchEnv) => {
|
||||
const preLaunchEnv = Object.fromEntries(
|
||||
Object.keys(launchEnv).map((key) => [key, userEnv[key]])
|
||||
)
|
||||
const env: Record<string, string> = { HOME: '/home/jin', ...userEnv, ...launchEnv }
|
||||
const ptySpawn = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('primary boom')
|
||||
})
|
||||
.mockReturnValue({ pid: 5 })
|
||||
|
||||
const result = spawnShellWithFallback({
|
||||
shellPath,
|
||||
shellArgs: ['-l'],
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/work',
|
||||
env,
|
||||
ptySpawn: ptySpawn as never,
|
||||
preLaunchEnv,
|
||||
getShellReadyConfig: () => ({ args: null, env: {} })
|
||||
})
|
||||
|
||||
expect(env).toEqual({ HOME: '/home/jin', SHELL: result.shellPath, ...userEnv })
|
||||
})
|
||||
|
||||
it('drops the first fallback’s launch env when a second fallback takes over', () => {
|
||||
// Scrub the last attempt keys; a once-only primary scrub would leak the first fallback env.
|
||||
const bashLaunchEnv = { ORCA_SHELL_FEATURES: 'markers', BASH_ENV: '/userdata/bash/rcfile' }
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import type * as WslPathOwner from '../../../shared/wsl-paths'
|
||||
import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability'
|
||||
import { assert, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as MacosTccLoginShell from '../macos-tcc-login-shell'
|
||||
import type * as ForegroundProcess from '../agent-foreground-process'
|
||||
import type { WindowsProcessRow } from '../../windows/windows-process-table'
|
||||
import {
|
||||
ptyLastRecognizedForeground,
|
||||
ptyProcesses,
|
||||
ptyIncarnations
|
||||
} from './local-pty-provider-state'
|
||||
import { inspectionShowsShellAlone } from '../../runtime/run-terminal-shell-alone'
|
||||
|
||||
const {
|
||||
fullTable,
|
||||
@@ -206,364 +199,9 @@ describe('LocalPtyProvider', () => {
|
||||
resolveScan({ available: true, processName: 'zsh' })
|
||||
await expect(inspection).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' })
|
||||
})
|
||||
|
||||
it('returns false when foreground process matches shell', async () => {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
expect(await provider.hasChildProcesses(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when foreground process differs from shell', async () => {
|
||||
mockProc.process = 'node'
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
expect(await provider.hasChildProcesses(id)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for unknown PTY ids', async () => {
|
||||
expect(await provider.hasChildProcesses('nonexistent')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'reconciles real Windows decision evidence (static=%s)',
|
||||
async (staticName) => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const actual = await vi.importActual<typeof ForegroundProcess>('../agent-foreground-process')
|
||||
resolveAgentForegroundProcessMock.mockImplementation(
|
||||
actual.resolveAgentForegroundProcessWithAvailability
|
||||
)
|
||||
mockProc.pid = 100
|
||||
mockProc.process = 'powershell.exe'
|
||||
Object.assign(mockProc, { processNameIsSpawnFile: staticName })
|
||||
const { id, incarnationId } = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
assert(incarnationId, 'fixture incarnation missing')
|
||||
const nativeRow = (
|
||||
pid: number,
|
||||
ppid: number,
|
||||
name: string,
|
||||
command = name
|
||||
): WindowsProcessRow => ({ pid, ppid, name, command, creationTimeMs: pid })
|
||||
const shell = nativeRow(100, 1, 'powershell.exe')
|
||||
const orphan = nativeRow(120, 999, 'claude.exe', 'claude.exe --resume conversation')
|
||||
const daemon = nativeRow(130, 1, 'ssh-agent.exe', 'ssh-agent.exe -D')
|
||||
type EvidenceOptions = {
|
||||
age?: number
|
||||
detailed?: WindowsProcessRow[]
|
||||
identities?: WindowsProcessRow[]
|
||||
scanFailed?: boolean
|
||||
jobFailed?: boolean
|
||||
consoleFailed?: boolean
|
||||
childFailed?: boolean
|
||||
}
|
||||
const cases: [string, number | null, WindowsProcessRow[], boolean, EvidenceOptions?][] = [
|
||||
['C1', null, [shell, daemon], true],
|
||||
['live young anchor', 120, [shell, orphan], false],
|
||||
['live old anchor', 120, [shell, orphan], false, { age: 40_000 }],
|
||||
['anchor identity missing', 120, [shell, orphan], false, { detailed: [shell] }],
|
||||
['anchor query denied', 120, [shell, nativeRow(120, 999, 'unknown.exe', '')], false],
|
||||
['anchored scan failed', 120, [shell, orphan], false, { scanFailed: true }],
|
||||
['job unavailable', 120, [shell, orphan], false, { jobFailed: true }],
|
||||
['anchor absent', 120, [shell, daemon], true],
|
||||
['anchor just exited', 120, [shell], true],
|
||||
[
|
||||
'foreign anchor',
|
||||
120,
|
||||
[shell, nativeRow(120, 999, 'ssh-agent.exe', 'ssh-agent.exe -D')],
|
||||
true
|
||||
],
|
||||
['attached agent', null, [shell, { ...orphan, ppid: 100 }], false],
|
||||
['ordinary background job', null, [shell, nativeRow(130, 100, 'sleep.exe')], false],
|
||||
['incomplete identity', null, [shell, daemon], false, { identities: [shell] }],
|
||||
['console unavailable', null, [shell, daemon], false, { consoleFailed: true }],
|
||||
['child read failed', null, [shell, daemon], false, { childFailed: true }],
|
||||
['unanchored degraded complete', null, [shell, daemon], true, { scanFailed: true }],
|
||||
[
|
||||
'unanchored degraded incomplete',
|
||||
null,
|
||||
[shell, daemon],
|
||||
false,
|
||||
{ scanFailed: true, identities: [shell] }
|
||||
]
|
||||
]
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
try {
|
||||
const seed = (pid: number | null) =>
|
||||
ptyLastRecognizedForeground.set(id, { name: 'claude', pid, at: 1000, steady: null })
|
||||
for (const [name, anchor, rows, admits, input = {}] of cases) {
|
||||
vi.setSystemTime(1000 + (input.age ?? 1000))
|
||||
seed(anchor)
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(
|
||||
input.jobFailed ? null : new Set(rows.map((row) => row.pid))
|
||||
)
|
||||
fullTable.mockReset().mockResolvedValue(input.detailed ?? rows)
|
||||
if (input.scanFailed) {
|
||||
fullTable.mockRejectedValue(new Error('denied'))
|
||||
}
|
||||
identityTable.mockReset().mockResolvedValue(input.identities ?? rows)
|
||||
if (input.childFailed) {
|
||||
identityTable.mockRejectedValue(new Error('denied'))
|
||||
}
|
||||
consoleMembers.mockResolvedValue(input.consoleFailed ? null : new Set([100]))
|
||||
const observed = await provider.inspectProcess(id, {
|
||||
expectedIncarnationId: incarnationId,
|
||||
scanChildProcesses: true,
|
||||
ignoreDetached: true
|
||||
})
|
||||
expect(inspectionShowsShellAlone(observed), name).toBe(admits)
|
||||
expect(fullTable, name).toHaveBeenCalledTimes(1)
|
||||
expect(identityTable.mock.calls.length, name).toBe(
|
||||
input.jobFailed || rows.length === 1 ? 0 : 1
|
||||
)
|
||||
}
|
||||
fullTable.mockReset().mockResolvedValue([shell, daemon])
|
||||
identityTable.mockReset().mockResolvedValue([shell, daemon])
|
||||
consoleMembers.mockResolvedValue(new Set([100]))
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 130]))
|
||||
for (const count of [2, 20]) {
|
||||
fullTable.mockClear()
|
||||
identityTable.mockClear()
|
||||
for (let i = 0; i < count; i++) {
|
||||
seed(null)
|
||||
expect(
|
||||
inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: true }))
|
||||
).toBe(true)
|
||||
}
|
||||
expect(fullTable).toHaveBeenCalledTimes(count)
|
||||
expect(identityTable).toHaveBeenCalledTimes(count)
|
||||
}
|
||||
seed(null)
|
||||
fullTable.mockClear()
|
||||
expect(await provider.getForegroundProcess(id)).toBe('claude')
|
||||
await expect(
|
||||
provider.inspectProcess(id, { steadyState: true, ignoreDetached: true })
|
||||
).resolves.toMatchObject({ foregroundProcess: 'claude' })
|
||||
expect(fullTable).not.toHaveBeenCalled()
|
||||
expect(
|
||||
inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: false }))
|
||||
).toBe(false)
|
||||
seed(120)
|
||||
fullTable.mockResolvedValue([shell, orphan])
|
||||
identityTable.mockResolvedValue([shell, orphan])
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 120]))
|
||||
expect(await provider.confirmForegroundProcess(id)).not.toBe('claude')
|
||||
for (const replacement of ['object', 'incarnation']) {
|
||||
const original = ptyProcesses.get(id)
|
||||
if (!original) {
|
||||
throw new Error('fixture PTY missing')
|
||||
}
|
||||
fullTable.mockImplementationOnce(async () => {
|
||||
if (replacement === 'object') {
|
||||
ptyProcesses.set(id, { ...original })
|
||||
} else {
|
||||
ptyIncarnations.set(id, 'replacement')
|
||||
}
|
||||
return [shell]
|
||||
})
|
||||
await expect(
|
||||
provider.inspectProcess(id, {
|
||||
expectedIncarnationId: incarnationId,
|
||||
ignoreDetached: true
|
||||
})
|
||||
).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' })
|
||||
ptyProcesses.set(id, original)
|
||||
ptyIncarnations.set(id, incarnationId)
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
describe('getForegroundProcess', () => {
|
||||
it('returns the process name', async () => {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
expect(await provider.getForegroundProcess(id)).toBe('zsh')
|
||||
})
|
||||
|
||||
it('uses the spawned Windows shell when node-pty reports only the terminal name', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mockProc.process = 'xterm-256color'
|
||||
|
||||
const { id } = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
|
||||
expect(await provider.getForegroundProcess(id)).toBe('powershell.exe')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
|
||||
mockProc.pid,
|
||||
'powershell.exe',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for unknown PTY ids', async () => {
|
||||
expect(await provider.getForegroundProcess('nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a recognized agent across an unavailable scan without adding probes', async () => {
|
||||
resolveAgentForegroundProcessMock
|
||||
.mockResolvedValueOnce({ available: true, processName: 'claude' })
|
||||
.mockResolvedValueOnce({ available: false, processName: 'powershell.exe' })
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('drops a delayed scan result after the PTY exits', async () => {
|
||||
let resolveScan!: (resolution: { available: boolean; processName: string }) => void
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveScan = resolve
|
||||
})
|
||||
)
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const foreground = provider.getForegroundProcess(id)
|
||||
exitCb?.({ exitCode: 0 })
|
||||
resolveScan({ available: true, processName: 'droid' })
|
||||
|
||||
await expect(foreground).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('confirms a still-active agent from job membership without a whole-table scan', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mockProc.process = 'powershell.exe'
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({
|
||||
available: true,
|
||||
processName: 'claude'
|
||||
})
|
||||
// A descendant beyond the shell is still in the pane's job.
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
// node-pty names only the shell; job membership revalidates the agent without another full scan.
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops trusting job membership once the cached agent goes stale', async () => {
|
||||
// An unanchored job superset must age out; WSL detached plumbing can otherwise pin agent identity forever.
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mockProc.process = 'powershell.exe'
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({
|
||||
available: true,
|
||||
processName: 'claude'
|
||||
})
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
try {
|
||||
vi.setSystemTime(1_000_000)
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(1_000_000 + 40_000)
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({
|
||||
available: true,
|
||||
processName: 'powershell.exe'
|
||||
})
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('powershell.exe')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not refresh the agent age after a degraded revalidation scan', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mockProc.process = 'powershell.exe'
|
||||
resolveAgentForegroundProcessMock
|
||||
.mockResolvedValueOnce({ available: true, processName: 'claude' })
|
||||
.mockResolvedValueOnce({ available: false, processName: null })
|
||||
.mockResolvedValue({ available: true, processName: 'powershell.exe' })
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
try {
|
||||
vi.setSystemTime(1_000_000)
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
|
||||
vi.setSystemTime(1_000_000 + 40_000)
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
|
||||
vi.setSystemTime(1_000_000 + 41_000)
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('powershell.exe')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps short-circuiting a live agent instead of scanning on every call', async () => {
|
||||
// Confirmation must reset age; measuring total runtime would permanently disable cheap rechecks (#9258).
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mockProc.process = 'powershell.exe'
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({
|
||||
available: true,
|
||||
processName: 'claude'
|
||||
})
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
try {
|
||||
vi.setSystemTime(1_000_000)
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(1_000_000 + 40_000)
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
vi.setSystemTime(1_000_000 + 45_000)
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls through to the scan when the job holds only the shell', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mockProc.process = 'powershell.exe'
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({
|
||||
available: true,
|
||||
processName: 'claude'
|
||||
})
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345]))
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the cached agent when both the console probe and process snapshot are inconclusive', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mockProc.process = 'powershell.exe'
|
||||
resolveAgentForegroundProcessMock
|
||||
.mockResolvedValueOnce({ available: true, processName: 'claude' })
|
||||
.mockResolvedValue({ available: true, processName: null })
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(null)
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retires an anchored agent immediately when its pid leaves the job, despite a leftover', async () => {
|
||||
// A missing anchor proves exit immediately; detached leftovers cannot preserve that identity.
|
||||
Object.defineProperty(process, 'platform', {
|
||||
@@ -587,72 +225,6 @@ describe('LocalPtyProvider', () => {
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('never expires an anchored agent the job still holds, even when scans miss it', async () => {
|
||||
// A live job anchor outranks incomplete scans and refreshes the age bound.
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
mockProc.process = 'powershell.exe'
|
||||
resolveAgentForegroundProcessMock
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
processName: 'claude',
|
||||
processId: 999
|
||||
})
|
||||
.mockResolvedValue({ available: true, processName: null })
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
try {
|
||||
vi.setSystemTime(1_000_000)
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
|
||||
vi.setSystemTime(1_000_000 + 40_000)
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
vi.setSystemTime(1_000_000 + 45_000)
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
vi.setSystemTime(1_000_000 + 80_000)
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('holds a restarting agent across a degraded scan instead of reporting an exit', async () => {
|
||||
// A degraded scan during restart cannot prove exit; retain unanchored identity until a fresh scan re-anchors.
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mockProc.process = 'powershell.exe'
|
||||
resolveAgentForegroundProcessMock
|
||||
.mockResolvedValueOnce({ available: true, processName: 'claude', processId: 999 })
|
||||
.mockResolvedValueOnce({ available: false, processName: null })
|
||||
.mockResolvedValue({ available: true, processName: 'claude', processId: 1000 })
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
try {
|
||||
vi.setSystemTime(1_000_000)
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 1000]))
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
vi.setSystemTime(1_000_000 + 40_000)
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
|
||||
vi.setSystemTime(1_000_000 + 45_000)
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops an anchored agent when the drift recheck proves the pid was recycled', async () => {
|
||||
// A recycled PID still in the job cannot preserve an identity contradicted by the scan.
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
@@ -679,40 +251,5 @@ describe('LocalPtyProvider', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('retires the cached agent after verified shell-only membership and a no-agent scan', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
mockProc.process = 'powershell.exe'
|
||||
resolveAgentForegroundProcessMock
|
||||
.mockResolvedValueOnce({ available: true, processName: 'claude' })
|
||||
.mockResolvedValue({ available: true, processName: null })
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345]))
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
|
||||
await expect(provider.getForegroundProcess(id)).resolves.toBeNull()
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmForegroundProcess', () => {
|
||||
it('drops a delayed result after the PTY exits', async () => {
|
||||
let resolveScan!: (resolution: { available: boolean; processName: string }) => void
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveScan = resolve
|
||||
})
|
||||
)
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const confirmation = provider.confirmForegroundProcess(id)
|
||||
exitCb?.({ exitCode: 0 })
|
||||
resolveScan({ available: true, processName: 'droid' })
|
||||
|
||||
await expect(confirmation).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
import type * as WslPathOwner from '../../../shared/wsl-paths'
|
||||
import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as MacosTccLoginShell from '../macos-tcc-login-shell'
|
||||
|
||||
const {
|
||||
existsSyncMock,
|
||||
statSyncMock,
|
||||
accessSyncMock,
|
||||
mkdirSyncMock,
|
||||
writeFileSyncMock,
|
||||
spawnMock,
|
||||
prepareMacosTccLoginShellMock,
|
||||
resolveAgentForegroundProcessMock,
|
||||
readWindowsPtyJobProcessIdsMock,
|
||||
killWithDescendantSweepMock,
|
||||
isWslAvailableAsyncMock,
|
||||
wslUncDirectoryExistsAsyncMock,
|
||||
createShellPromptReadinessProbeMock
|
||||
} = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
accessSyncMock: vi.fn(),
|
||||
mkdirSyncMock: vi.fn(),
|
||||
writeFileSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
prepareMacosTccLoginShellMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn(),
|
||||
readWindowsPtyJobProcessIdsMock: vi.fn(),
|
||||
killWithDescendantSweepMock: vi.fn(),
|
||||
isWslAvailableAsyncMock: vi.fn(),
|
||||
wslUncDirectoryExistsAsyncMock: vi.fn(),
|
||||
createShellPromptReadinessProbeMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslPathOwner>()),
|
||||
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
|
||||
toWindowsWslPath: (path: string, distro: string) =>
|
||||
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
statSync: statSyncMock,
|
||||
accessSync: accessSyncMock,
|
||||
mkdirSync: mkdirSyncMock,
|
||||
writeFileSync: writeFileSyncMock,
|
||||
chmodSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
constants: { X_OK: 1 }
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/tmp/orca-user-data')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MacosTccLoginShell>()),
|
||||
prepareMacosTccLoginShell: prepareMacosTccLoginShellMock
|
||||
}))
|
||||
|
||||
vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({
|
||||
killWithDescendantSweep: killWithDescendantSweepMock
|
||||
}))
|
||||
|
||||
// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests.
|
||||
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
vi.mock('../windows-powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe'
|
||||
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
|
||||
: [WINDOWS_POWERSHELL_ABS, CMD_ABS],
|
||||
getWindowsCmdPath: () => CMD_ABS
|
||||
}))
|
||||
|
||||
vi.mock('../agent-foreground-process', () => ({
|
||||
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
|
||||
resolveAgentForegroundProcessMock(...args)
|
||||
}))
|
||||
|
||||
vi.mock('../windows-pty-job-membership', () => ({
|
||||
readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args),
|
||||
isWindowsPtyJobReadable: () => true
|
||||
}))
|
||||
|
||||
vi.mock('../../wsl/filesystem/unc-path-access', () => ({
|
||||
parseWslPath: (path: string) => {
|
||||
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
distro: match[1],
|
||||
linuxPath: (match[2] || '').replace(/\\/g, '/') || '/'
|
||||
}
|
||||
},
|
||||
wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args)
|
||||
}))
|
||||
vi.mock('../../wsl/discovery/distro-catalog', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu'
|
||||
}))
|
||||
|
||||
vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslAvailabilityTypes>()),
|
||||
isWslAvailableAsync: () => isWslAvailableAsyncMock()
|
||||
}))
|
||||
|
||||
vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
|
||||
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
|
||||
}))
|
||||
|
||||
import { LocalPtyProvider } from './local-pty-provider'
|
||||
import {
|
||||
applyLocalPtyProviderMockDefaults,
|
||||
createLocalPtyMockProcess,
|
||||
installLocalPtyProviderEnvSandbox,
|
||||
type LocalPtyMockProcess
|
||||
} from './local-pty-provider-test-harness'
|
||||
|
||||
describe('LocalPtyProvider', () => {
|
||||
let provider: LocalPtyProvider
|
||||
let mockProc: LocalPtyMockProcess
|
||||
let exitCb: ((info: { exitCode: number; signal?: number }) => void) | undefined
|
||||
|
||||
installLocalPtyProviderEnvSandbox()
|
||||
|
||||
beforeEach(() => {
|
||||
applyLocalPtyProviderMockDefaults({
|
||||
existsSyncMock,
|
||||
statSyncMock,
|
||||
accessSyncMock,
|
||||
mkdirSyncMock,
|
||||
writeFileSyncMock,
|
||||
prepareMacosTccLoginShellMock,
|
||||
resolveAgentForegroundProcessMock,
|
||||
readWindowsPtyJobProcessIdsMock,
|
||||
killWithDescendantSweepMock,
|
||||
isWslAvailableAsyncMock,
|
||||
wslUncDirectoryExistsAsyncMock,
|
||||
createShellPromptReadinessProbeMock
|
||||
})
|
||||
|
||||
exitCb = undefined
|
||||
mockProc = createLocalPtyMockProcess({
|
||||
get: () => exitCb,
|
||||
set: (cb) => {
|
||||
exitCb = cb
|
||||
}
|
||||
})
|
||||
spawnMock.mockReturnValue(mockProc)
|
||||
|
||||
provider = new LocalPtyProvider()
|
||||
})
|
||||
|
||||
it('retains the fallback owner cause when output and exit precede the spawn reply', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mockProc.onData.mockImplementation((cb) => {
|
||||
cb('too many consoles in use, max consoles is 128')
|
||||
return { dispose: vi.fn() }
|
||||
})
|
||||
mockProc.onExit.mockImplementation((cb) => {
|
||||
cb({ exitCode: 256 })
|
||||
return { dispose: vi.fn() }
|
||||
})
|
||||
const result = await provider.spawn({ cols: 80, rows: 24 })
|
||||
expect(result).toMatchObject({
|
||||
exitedBeforeSpawnReply: true,
|
||||
exitCause: {
|
||||
kind: 'exited',
|
||||
exitCode: 256,
|
||||
initialOutputDiagnostic: 'git-bash-console-capacity'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('write', () => {
|
||||
it('writes data to the PTY process', async () => {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
expect(provider.write(id, 'hello')).toBe(true)
|
||||
expect(mockProc.write).toHaveBeenCalledWith('hello')
|
||||
})
|
||||
|
||||
it('is a no-op for unknown PTY ids', () => {
|
||||
expect(provider.write('nonexistent', 'hello')).toBe(false)
|
||||
expect(mockProc.write).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resize', () => {
|
||||
it('resizes the PTY process', async () => {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
provider.resize(id, 120, 40)
|
||||
expect(mockProc.resize).toHaveBeenCalledWith(120, 40)
|
||||
})
|
||||
})
|
||||
|
||||
describe('producer flow control', () => {
|
||||
it('pauses and resumes the node-pty process directly', async () => {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
provider.pauseProducer(id)
|
||||
expect(mockProc.pause).toHaveBeenCalledTimes(1)
|
||||
provider.resumeProducer(id)
|
||||
expect(mockProc.resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is a no-op for unknown PTY ids', () => {
|
||||
expect(() => {
|
||||
provider.pauseProducer('nonexistent')
|
||||
provider.resumeProducer('nonexistent')
|
||||
}).not.toThrow()
|
||||
expect(mockProc.pause).not.toHaveBeenCalled()
|
||||
expect(mockProc.resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows node-pty throws from a torn-down PTY', async () => {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
mockProc.pause.mockImplementation(() => {
|
||||
throw new Error('read EIO')
|
||||
})
|
||||
mockProc.resume.mockImplementation(() => {
|
||||
throw new Error('read EIO')
|
||||
})
|
||||
expect(() => {
|
||||
provider.pauseProducer(id)
|
||||
provider.resumeProducer(id)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event listeners', () => {
|
||||
it('notifies data listeners when PTY produces output', async () => {
|
||||
const dataHandler = vi.fn()
|
||||
provider.onData(dataHandler)
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const onDataCb = mockProc.onData.mock.calls[0][0]
|
||||
onDataCb('hello world')
|
||||
|
||||
expect(dataHandler).toHaveBeenCalledWith({ id, data: 'hello world' })
|
||||
})
|
||||
|
||||
it('classifies startup queries before runtime and public data listeners', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const runtimeData = vi.fn()
|
||||
const dataHandler = vi.fn()
|
||||
provider.configure({ onData: runtimeData })
|
||||
provider.onData(dataHandler)
|
||||
const { id } = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
startupIngress: {
|
||||
colors: { foreground: '#2e3434', background: '#ffffff' },
|
||||
deadlineMs: 5_000
|
||||
}
|
||||
})
|
||||
const onDataCb = mockProc.onData.mock.calls[0][0]
|
||||
const query = '\x1b]10;?\x07'
|
||||
const echo = ']10;rgb:2e2e/3434/3434\\'
|
||||
|
||||
onDataCb(query)
|
||||
onDataCb(echo)
|
||||
onDataCb('prompt')
|
||||
|
||||
expect(mockProc.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\')
|
||||
expect(runtimeData.mock.calls.map((call) => call.slice(1))).toEqual([
|
||||
['', expect.any(Number), query.length, true],
|
||||
['', expect.any(Number), echo.length, true],
|
||||
['prompt', expect.any(Number)]
|
||||
])
|
||||
expect(dataHandler.mock.calls.map(([payload]) => payload)).toEqual([
|
||||
{
|
||||
id,
|
||||
data: '',
|
||||
sequenceChars: query.length,
|
||||
seq: query.length,
|
||||
transformed: true,
|
||||
runtimeFed: true
|
||||
},
|
||||
{
|
||||
id,
|
||||
data: '',
|
||||
sequenceChars: echo.length,
|
||||
seq: query.length + echo.length,
|
||||
transformed: true,
|
||||
runtimeFed: true
|
||||
},
|
||||
{ id, data: 'prompt', runtimeFed: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('answers a native Windows OSC color query from pushed colours before renderer delivery', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const dataHandler = vi.fn()
|
||||
provider.onData(dataHandler)
|
||||
const { id } = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
const onDataCb = mockProc.onData.mock.calls[0][0]
|
||||
const query = '\x1b]10;?\x07'
|
||||
|
||||
onDataCb(query)
|
||||
provider.setColorQueryReplyColors({ foreground: '#123456', background: '#000000' })
|
||||
onDataCb(query)
|
||||
|
||||
expect(dataHandler).toHaveBeenCalledWith({
|
||||
id,
|
||||
data: '',
|
||||
sequenceChars: query.length,
|
||||
seq: query.length,
|
||||
transformed: true
|
||||
})
|
||||
expect(mockProc.write.mock.calls).toEqual([
|
||||
['\x1b]10;rgb:ffff/ffff/ffff\x1b\\'],
|
||||
['\x1b]10;rgb:1212/3434/5656\x1b\\']
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps forwarded OSC color replies for a Windows-owned WSL PTY', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const { id } = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Ubuntu'
|
||||
})
|
||||
const onDataCb = mockProc.onData.mock.calls[0][0]
|
||||
const reply = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\'
|
||||
|
||||
onDataCb('\x1b]11;?\x07')
|
||||
provider.write(id, reply)
|
||||
|
||||
expect(mockProc.write).toHaveBeenCalledWith(reply)
|
||||
})
|
||||
|
||||
it('notifies exit listeners when PTY exits', async () => {
|
||||
const exitHandler = vi.fn()
|
||||
provider.onExit(exitHandler)
|
||||
const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
exitCb?.({ exitCode: 0 })
|
||||
|
||||
expect(exitHandler).toHaveBeenCalledWith({
|
||||
id,
|
||||
code: 0,
|
||||
incarnationId,
|
||||
cause: { kind: 'exited', exitCode: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a signalled death as a signal, not as the zero node-pty pairs with it', async () => {
|
||||
const exitHandler = vi.fn()
|
||||
provider.onExit(exitHandler)
|
||||
const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
// node-pty can report SIGKILL as exitCode 0 with signal 9; dropping signal misclassifies crashes (STA-4536).
|
||||
exitCb?.({ exitCode: 0, signal: 9 })
|
||||
|
||||
expect(exitHandler).toHaveBeenCalledWith({
|
||||
id,
|
||||
code: 0,
|
||||
incarnationId,
|
||||
cause: { kind: 'signaled', signal: 9 }
|
||||
})
|
||||
})
|
||||
|
||||
it('allows unsubscribing from events', async () => {
|
||||
const dataHandler = vi.fn()
|
||||
const unsub = provider.onData(dataHandler)
|
||||
const { id: _id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
unsub()
|
||||
const onDataCb = mockProc.onData.mock.calls[0][0]
|
||||
onDataCb('hello')
|
||||
|
||||
expect(dataHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,375 +0,0 @@
|
||||
import type * as WslPathOwner from '../../../shared/wsl-paths'
|
||||
import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as MacosTccLoginShell from '../macos-tcc-login-shell'
|
||||
|
||||
const {
|
||||
existsSyncMock,
|
||||
statSyncMock,
|
||||
accessSyncMock,
|
||||
mkdirSyncMock,
|
||||
writeFileSyncMock,
|
||||
spawnMock,
|
||||
prepareMacosTccLoginShellMock,
|
||||
resolveAgentForegroundProcessMock,
|
||||
readWindowsPtyJobProcessIdsMock,
|
||||
killWithDescendantSweepMock,
|
||||
isWslAvailableAsyncMock,
|
||||
wslUncDirectoryExistsAsyncMock,
|
||||
createShellPromptReadinessProbeMock
|
||||
} = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
accessSyncMock: vi.fn(),
|
||||
mkdirSyncMock: vi.fn(),
|
||||
writeFileSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
prepareMacosTccLoginShellMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn(),
|
||||
readWindowsPtyJobProcessIdsMock: vi.fn(),
|
||||
killWithDescendantSweepMock: vi.fn(),
|
||||
isWslAvailableAsyncMock: vi.fn(),
|
||||
wslUncDirectoryExistsAsyncMock: vi.fn(),
|
||||
createShellPromptReadinessProbeMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslPathOwner>()),
|
||||
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
|
||||
toWindowsWslPath: (path: string, distro: string) =>
|
||||
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
statSync: statSyncMock,
|
||||
accessSync: accessSyncMock,
|
||||
mkdirSync: mkdirSyncMock,
|
||||
writeFileSync: writeFileSyncMock,
|
||||
chmodSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
constants: { X_OK: 1 }
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/tmp/orca-user-data')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MacosTccLoginShell>()),
|
||||
prepareMacosTccLoginShell: prepareMacosTccLoginShellMock
|
||||
}))
|
||||
|
||||
vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({
|
||||
killWithDescendantSweep: killWithDescendantSweepMock
|
||||
}))
|
||||
|
||||
// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests.
|
||||
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
vi.mock('../windows-powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe'
|
||||
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
|
||||
: [WINDOWS_POWERSHELL_ABS, CMD_ABS],
|
||||
getWindowsCmdPath: () => CMD_ABS
|
||||
}))
|
||||
|
||||
vi.mock('../agent-foreground-process', () => ({
|
||||
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
|
||||
resolveAgentForegroundProcessMock(...args)
|
||||
}))
|
||||
|
||||
vi.mock('../windows-pty-job-membership', () => ({
|
||||
readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args),
|
||||
isWindowsPtyJobReadable: () => true
|
||||
}))
|
||||
|
||||
vi.mock('../../wsl/filesystem/unc-path-access', () => ({
|
||||
parseWslPath: (path: string) => {
|
||||
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
distro: match[1],
|
||||
linuxPath: (match[2] || '').replace(/\\/g, '/') || '/'
|
||||
}
|
||||
},
|
||||
wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args)
|
||||
}))
|
||||
vi.mock('../../wsl/discovery/distro-catalog', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu'
|
||||
}))
|
||||
|
||||
vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslAvailabilityTypes>()),
|
||||
isWslAvailableAsync: () => isWslAvailableAsyncMock()
|
||||
}))
|
||||
|
||||
vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
|
||||
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
|
||||
}))
|
||||
|
||||
import { LocalPtyProvider } from './local-pty-provider'
|
||||
import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../../pty/shell-startup/posix-shell-startup-command'
|
||||
import {
|
||||
applyLocalPtyProviderMockDefaults,
|
||||
createLocalPtyMockProcess,
|
||||
installLocalPtyProviderEnvSandbox,
|
||||
type LocalPtyMockProcess
|
||||
} from './local-pty-provider-test-harness'
|
||||
|
||||
describe('LocalPtyProvider', () => {
|
||||
let provider: LocalPtyProvider
|
||||
let mockProc: LocalPtyMockProcess
|
||||
let exitCb: ((info: { exitCode: number }) => void) | undefined
|
||||
|
||||
installLocalPtyProviderEnvSandbox()
|
||||
|
||||
beforeEach(() => {
|
||||
applyLocalPtyProviderMockDefaults({
|
||||
existsSyncMock,
|
||||
statSyncMock,
|
||||
accessSyncMock,
|
||||
mkdirSyncMock,
|
||||
writeFileSyncMock,
|
||||
prepareMacosTccLoginShellMock,
|
||||
resolveAgentForegroundProcessMock,
|
||||
readWindowsPtyJobProcessIdsMock,
|
||||
killWithDescendantSweepMock,
|
||||
isWslAvailableAsyncMock,
|
||||
wslUncDirectoryExistsAsyncMock,
|
||||
createShellPromptReadinessProbeMock
|
||||
})
|
||||
|
||||
exitCb = undefined
|
||||
mockProc = createLocalPtyMockProcess({
|
||||
get: () => exitCb,
|
||||
set: (cb) => {
|
||||
exitCb = cb
|
||||
}
|
||||
})
|
||||
spawnMock.mockReturnValue(mockProc)
|
||||
|
||||
provider = new LocalPtyProvider()
|
||||
})
|
||||
|
||||
describe('spawn', () => {
|
||||
it('delegates markerless Codex startup to the shell wrapper without a PTY write', async () => {
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
const command = "codex '--dangerously-bypass-approvals-and-sandbox'"
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24, command })
|
||||
|
||||
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(command)
|
||||
expect(spawnEnv.ORCA_SHELL_FEATURES).toContain('startup')
|
||||
expect(spawnEnv.ORCA_SHELL_FEATURES).not.toContain('ready')
|
||||
expect(mockProc.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps payload-bearing Codex startup in the wrapper readiness path', async () => {
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
const command = "codex '--dangerously-bypass-approvals-and-sandbox' 'review this branch'"
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
command,
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
|
||||
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(command)
|
||||
expect(spawnEnv.ORCA_SHELL_FEATURES).toContain('startup')
|
||||
expect(spawnEnv.ORCA_SHELL_FEATURES).toContain('ready')
|
||||
expect(mockProc.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scrubs an inherited wrapper startup command from ordinary panes', async () => {
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: { [POSIX_SHELL_STARTUP_COMMAND_ENV]: 'poisoned command' }
|
||||
})
|
||||
|
||||
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined()
|
||||
expect(mockProc.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains PTY delivery for unsupported local shells without leaking wrapper state', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
process.env.SHELL = '/bin/sh'
|
||||
const command = "codex '--dangerously-bypass-approvals-and-sandbox'"
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24, command })
|
||||
|
||||
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined()
|
||||
expect(mockProc.write).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(mockProc.write).toHaveBeenCalledWith(`${command}\r`)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('stages a long startup command and types only the line that sources it', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
process.env.SHELL = '/bin/sh'
|
||||
const command = `claude '${'x'.repeat(600)}'`
|
||||
|
||||
const result = await provider.spawn({ cols: 80, rows: 24, command })
|
||||
|
||||
expect(result).not.toHaveProperty('startupDelivery')
|
||||
const staged = writeFileSyncMock.mock.calls.find(([path]) =>
|
||||
String(path).includes('orca-launch-')
|
||||
)
|
||||
expect(staged?.[1]).toContain(`\n${command}\n`)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(mockProc.write).toHaveBeenCalledWith(`. '${staged?.[0]}'\r`)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('prints a notice in the terminal when it types a line it could not stage', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
process.env.SHELL = '/bin/sh'
|
||||
const received: string[] = []
|
||||
provider.configure({ onData: (_id, data) => received.push(data) })
|
||||
writeFileSyncMock.mockImplementationOnce(() => {
|
||||
throw new Error('ENOSPC: no space left on device')
|
||||
})
|
||||
const command = `claude '${'x'.repeat(600)}'`
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24, command })
|
||||
|
||||
expect(received.join('')).toContain(
|
||||
'[orca] Could not stage the launch command (ENOSPC: no space left on device)'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(mockProc.write).toHaveBeenCalledWith(`${command}\r`)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies shell identity against the exact spawn PATH', async () => {
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => ({ ...env, PATH: '/post-hook/bin' })
|
||||
})
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
command: 'printf ready',
|
||||
env: { PATH: '/pre-hook/bin' }
|
||||
})
|
||||
|
||||
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/post-hook/bin')
|
||||
expect(createShellPromptReadinessProbeMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ shellPathEnv: '/post-hook/bin' })
|
||||
)
|
||||
})
|
||||
|
||||
// This case consumes the preceding default-shell spawn in mock.calls[0]; the spy accumulates across tests.
|
||||
it('uses fallback shell readiness when startup-command shell spawn falls back', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
process.env.SHELL = '/usr/bin/fish'
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
throw new Error('fish failed')
|
||||
})
|
||||
spawnMock.mockReturnValue(mockProc)
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24, command: "printf 'linked issue context'" })
|
||||
|
||||
expect(spawnMock.mock.calls[0]?.[0]).toBe('/bin/zsh')
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(50)
|
||||
await Promise.resolve()
|
||||
expect(mockProc.write).not.toHaveBeenCalled()
|
||||
|
||||
const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void
|
||||
dataCallback('\x1b]777;orca-shell-ready\x07user@host % ')
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(29)
|
||||
await Promise.resolve()
|
||||
expect(mockProc.write).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await Promise.resolve()
|
||||
expect(mockProc.write).toHaveBeenCalledWith("printf 'linked issue context'\r")
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('releases held marker-prefix bytes when local shell readiness times out', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onData = vi.fn()
|
||||
provider.configure({ onData })
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' })
|
||||
const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void
|
||||
|
||||
dataCallback('\x1b]777;orca-shell-ready')
|
||||
expect(onData).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1500)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(onData).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'\x1b]777;orca-shell-ready',
|
||||
expect.any(Number)
|
||||
)
|
||||
expect(mockProc.write).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(200)
|
||||
await Promise.resolve()
|
||||
expect(mockProc.write).toHaveBeenCalledWith('printf ready\r')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('releases held marker-prefix bytes when local shell exits before readiness', async () => {
|
||||
const onData = vi.fn()
|
||||
provider.configure({ onData })
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' })
|
||||
const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void
|
||||
|
||||
dataCallback('\x1b]777;orca-shell-ready')
|
||||
expect(onData).not.toHaveBeenCalled()
|
||||
|
||||
exitCb?.({ exitCode: 0 })
|
||||
|
||||
expect(onData).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'\x1b]777;orca-shell-ready',
|
||||
expect.any(Number)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -130,9 +130,7 @@ vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({
|
||||
vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
|
||||
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
|
||||
}))
|
||||
|
||||
import {
|
||||
LOCAL_PTY_FORCE_KILL_RETRY_MS,
|
||||
LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS,
|
||||
LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS
|
||||
} from './local-pty-termination'
|
||||
@@ -182,45 +180,6 @@ describe('LocalPtyProvider', () => {
|
||||
})
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('kills the PTY process', async () => {
|
||||
// Cleanup replaces kill to prevent recycled-PID SIGHUP; capture the spy before shutdown (docs/fix-pty-fd-leak.md).
|
||||
const killSpy = mockProc.kill
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
await provider.shutdown(id, { immediate: true })
|
||||
expect(killSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes onExit callback via the node-pty exit handler', async () => {
|
||||
const onExit = vi.fn()
|
||||
provider.configure({ onExit })
|
||||
const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
await provider.shutdown(id, { immediate: true })
|
||||
expect(onExit).toHaveBeenCalledWith(id, -1, incarnationId, {
|
||||
kind: 'operator_close'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not destroy after an intentional Windows shutdown kill', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const killSpy = vi.fn()
|
||||
const destroySpy = vi.fn(() => {
|
||||
killSpy()
|
||||
})
|
||||
spawnMock.mockReturnValue({
|
||||
...mockProc,
|
||||
kill: killSpy,
|
||||
destroy: destroySpy
|
||||
})
|
||||
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
const shutdown = provider.shutdown(id, { immediate: true })
|
||||
exitCb?.({ exitCode: -1 })
|
||||
await shutdown
|
||||
|
||||
expect(killSpy).toHaveBeenCalledTimes(1)
|
||||
expect(destroySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps shutdown and ownership pending until node-pty reports physical exit', async () => {
|
||||
const killSpy = vi.fn()
|
||||
mockProc.kill = killSpy
|
||||
@@ -240,107 +199,6 @@ describe('LocalPtyProvider', () => {
|
||||
expect(provider.hasPty(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps physical-exit tracking when orphan cleanup races immediate shutdown', async () => {
|
||||
const killSpy = vi.fn(() => {
|
||||
queueMicrotask(() => exitCb?.({ exitCode: 137 }))
|
||||
})
|
||||
mockProc.kill = killSpy
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const shutdown = provider.shutdown(id, { immediate: true })
|
||||
expect(provider.killOrphanedPtys(1)).toEqual([{ id }])
|
||||
expect(provider.hasPty(id)).toBe(true)
|
||||
|
||||
await expect(shutdown).resolves.toBeUndefined()
|
||||
expect(killSpy).toHaveBeenCalledTimes(1)
|
||||
expect(provider.hasPty(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('escalates graceful shutdown before orphan cleanup disables the kill handle', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const killSpy = vi.fn()
|
||||
mockProc.kill = killSpy
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const graceful = provider.shutdown(id, { immediate: false })
|
||||
expect(killSpy.mock.calls).toEqual([['SIGTERM']])
|
||||
|
||||
expect(provider.killOrphanedPtys(1)).toEqual([{ id }])
|
||||
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
|
||||
|
||||
// Immediate escalation must cancel the graceful timer before it can invoke the neutralized kill.
|
||||
await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS)
|
||||
expect(killSpy).toHaveBeenCalledTimes(2)
|
||||
exitCb?.({ exitCode: 137 })
|
||||
await graceful
|
||||
expect(provider.hasPty(id)).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('escalates a graceful shutdown when destructive cleanup joins it', async () => {
|
||||
const killSpy = vi.fn()
|
||||
mockProc.kill = killSpy
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const graceful = provider.shutdown(id, { immediate: false })
|
||||
const immediate = provider.shutdown(id, { immediate: true })
|
||||
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
|
||||
|
||||
exitCb?.({ exitCode: 137 })
|
||||
await Promise.all([graceful, immediate])
|
||||
expect(provider.hasPty(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('force-kills a POSIX PTY that ignores graceful shutdown', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const killSpy = vi.fn()
|
||||
mockProc.kill = killSpy
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const graceful = provider.shutdown(id, { immediate: false })
|
||||
expect(killSpy.mock.calls).toEqual([['SIGTERM']])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS)
|
||||
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
|
||||
exitCb?.({ exitCode: 137 })
|
||||
await graceful
|
||||
expect(provider.hasPty(id)).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the kill handle until a graceful-deadline sweep settles when orphan cleanup joins', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const killSpy = vi.fn()
|
||||
const destroySpy = vi.fn()
|
||||
spawnMock.mockReturnValue({ ...mockProc, kill: killSpy, destroy: destroySpy })
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
const table = Promise.withResolvers<string>()
|
||||
readProcessTableMock.mockReturnValueOnce(table.promise)
|
||||
|
||||
const graceful = provider.shutdown(id, { immediate: false })
|
||||
await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS)
|
||||
expect(provider.killOrphanedPtys(1)).toEqual([{ id }])
|
||||
expect(destroySpy).not.toHaveBeenCalled()
|
||||
|
||||
table.resolve('')
|
||||
await vi.waitFor(() => expect(destroySpy).toHaveBeenCalledOnce())
|
||||
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
|
||||
exitCb?.({ exitCode: 137 })
|
||||
await graceful
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('escalates through orphan cleanup when the graceful-deadline sweep it joined is refused', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -374,50 +232,6 @@ describe('LocalPtyProvider', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a rejected graceful-deadline SIGKILL before the physical timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let forceAttempts = 0
|
||||
const killSpy = vi.fn((signal: string) => {
|
||||
if (signal === 'SIGKILL' && forceAttempts++ === 0) {
|
||||
throw new Error('transient force-kill failure')
|
||||
}
|
||||
})
|
||||
mockProc.kill = killSpy
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const graceful = provider.shutdown(id, { immediate: false })
|
||||
await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS)
|
||||
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
|
||||
expect(provider.hasPty(id)).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(LOCAL_PTY_FORCE_KILL_RETRY_MS)
|
||||
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL']])
|
||||
exitCb?.({ exitCode: 137 })
|
||||
await graceful
|
||||
expect(provider.hasPty(id)).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not double-kill ConPTY when destructive cleanup joins shutdown', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const killSpy = vi.fn()
|
||||
mockProc.kill = killSpy
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const graceful = provider.shutdown(id, { immediate: false })
|
||||
const immediate = provider.shutdown(id, { immediate: true })
|
||||
expect(killSpy.mock.calls).toEqual([[]])
|
||||
|
||||
exitCb?.({ exitCode: 137 })
|
||||
await Promise.all([graceful, immediate])
|
||||
expect(killSpy).toHaveBeenCalledTimes(1)
|
||||
expect(provider.hasPty(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a physical-exit timeout but retains the owner for a successful retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -454,231 +268,5 @@ describe('LocalPtyProvider', () => {
|
||||
await expect(provider.shutdown(id, { immediate: true })).resolves.toBeUndefined()
|
||||
expect(provider.hasPty(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels pending shell-ready startup delivery on forced shutdown', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' })
|
||||
|
||||
await provider.shutdown(id, { immediate: true })
|
||||
vi.advanceTimersByTime(2000)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockProc.write).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('is a no-op for unknown PTY ids', async () => {
|
||||
await provider.shutdown('nonexistent', { immediate: true })
|
||||
expect(mockProc.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for an in-flight agent shutdown before reusing the same session id', async () => {
|
||||
let releaseSweep!: () => void
|
||||
killWithDescendantSweepMock.mockImplementation(
|
||||
(_rootPid: number, killRoot: () => void) =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseSweep = () => {
|
||||
killRoot()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
)
|
||||
const spawnArgs = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'stable-agent-session',
|
||||
launchAgent: 'claude' as const
|
||||
}
|
||||
const spawnCallsBefore = spawnMock.mock.calls.length
|
||||
const { id } = await provider.spawn(spawnArgs)
|
||||
|
||||
const shutdown = provider.shutdown(id, { immediate: true })
|
||||
const respawn = provider.spawn(spawnArgs)
|
||||
await Promise.resolve()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(spawnCallsBefore + 1)
|
||||
|
||||
releaseSweep()
|
||||
await shutdown
|
||||
await respawn
|
||||
expect(spawnMock).toHaveBeenCalledTimes(spawnCallsBefore + 2)
|
||||
})
|
||||
|
||||
it('coalesces duplicate shutdown while descendant sweep is pending', async () => {
|
||||
let releaseSweep!: () => void
|
||||
killWithDescendantSweepMock.mockImplementation(
|
||||
(_rootPid: number, killRoot: () => void) =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseSweep = () => {
|
||||
killRoot()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
)
|
||||
const { id } = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
|
||||
const first = provider.shutdown(id, { immediate: true })
|
||||
const second = provider.shutdown(id, { immediate: true })
|
||||
expect(killWithDescendantSweepMock).toHaveBeenCalledOnce()
|
||||
releaseSweep()
|
||||
await Promise.all([first, second])
|
||||
expect(killWithDescendantSweepMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not terminate descendants after the tracked root exits mid-sweep', async () => {
|
||||
const terminateDescendants = vi.fn()
|
||||
let releaseSweep!: () => void
|
||||
killWithDescendantSweepMock.mockImplementation(
|
||||
(_rootPid: number, killRoot: () => void, deps?: { ownsRoot?: () => boolean }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseSweep = () => {
|
||||
// Production killWithDescendantSweep only signals descendants while ownsRoot.
|
||||
if (deps?.ownsRoot?.() ?? true) {
|
||||
terminateDescendants()
|
||||
}
|
||||
killRoot()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
)
|
||||
const { id } = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
|
||||
const shutdown = provider.shutdown(id, { immediate: true })
|
||||
exitCb?.({ exitCode: 0 })
|
||||
releaseSweep()
|
||||
await shutdown
|
||||
|
||||
expect(terminateDescendants).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('win32 immediate shutdown of a plain shell taskkills the descendant tree', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await provider.shutdown(id, { immediate: true })
|
||||
|
||||
// Windows orphan descendants hold ConPTY and cwd open; tree sweep allows worktree removal.
|
||||
expect(killWithDescendantSweepMock).toHaveBeenCalledWith(
|
||||
mockProc.pid,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ ownsRoot: expect.any(Function) })
|
||||
)
|
||||
})
|
||||
|
||||
it('win32 graceful shutdown of a plain shell does not taskkill the tree', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await provider.shutdown(id, { immediate: false })
|
||||
|
||||
expect(killWithDescendantSweepMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('POSIX immediate shutdown sweeps detached OMP tools without startup recognition', async () => {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await provider.shutdown(id, { immediate: true })
|
||||
|
||||
expect(killWithDescendantSweepMock).toHaveBeenCalledWith(
|
||||
mockProc.pid,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ ownsRoot: expect.any(Function) })
|
||||
)
|
||||
// Root group SIGKILL must wait for the descendant SIGTERM grace, or Claude skips SessionEnd.
|
||||
expect(killWithDescendantSweepMock.mock.calls[0][2].awaitEscalation()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('killAll', () => {
|
||||
it('kills all PTY processes', async () => {
|
||||
// Separate processes/callbacks prevent one exit cleanup from neutralizing or removing the other PTY.
|
||||
let exit1: ((e: { exitCode: number }) => void) | undefined
|
||||
let exit2: ((e: { exitCode: number }) => void) | undefined
|
||||
const mock1Kill = vi.fn(() => exit1?.({ exitCode: -1 }))
|
||||
const mock2Kill = vi.fn(() => exit2?.({ exitCode: -1 }))
|
||||
spawnMock
|
||||
.mockReturnValueOnce({
|
||||
...mockProc,
|
||||
kill: mock1Kill,
|
||||
onExit: vi.fn((cb) => {
|
||||
exit1 = cb
|
||||
})
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
...mockProc,
|
||||
kill: mock2Kill,
|
||||
onExit: vi.fn((cb) => {
|
||||
exit2 = cb
|
||||
})
|
||||
})
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
provider.killAll()
|
||||
|
||||
expect(mock1Kill).toHaveBeenCalled()
|
||||
expect(mock2Kill).toHaveBeenCalled()
|
||||
const list = await provider.listProcesses()
|
||||
expect(list).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('force-kills an OMP PTY root during app quit', async () => {
|
||||
const killSpy = vi.fn()
|
||||
spawnMock.mockReturnValue({
|
||||
...mockProc,
|
||||
kill: killSpy
|
||||
})
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24, launchAgent: 'omp' })
|
||||
|
||||
provider.killAll()
|
||||
|
||||
expect(killSpy).toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('does not destroy after intentional Windows orphan kills', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const destroySpy = vi.fn()
|
||||
const killSpy = vi.fn()
|
||||
spawnMock.mockReturnValue({
|
||||
...mockProc,
|
||||
kill: killSpy,
|
||||
destroy: destroySpy
|
||||
})
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
provider.killAll()
|
||||
|
||||
expect(killSpy).toHaveBeenCalledTimes(1)
|
||||
expect(destroySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('settles an overlapping shutdown when app quit takes final ownership', async () => {
|
||||
mockProc.kill.mockImplementation(() => undefined)
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
const shutdown = provider.shutdown(id, { immediate: true })
|
||||
let settled = false
|
||||
void shutdown.then(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
provider.killAll()
|
||||
|
||||
await expect(shutdown).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import type * as WslPathOwner from '../../../shared/wsl-paths'
|
||||
import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as MacosTccLoginShell from '../macos-tcc-login-shell'
|
||||
|
||||
const {
|
||||
existsSyncMock,
|
||||
statSyncMock,
|
||||
accessSyncMock,
|
||||
mkdirSyncMock,
|
||||
writeFileSyncMock,
|
||||
spawnMock,
|
||||
prepareMacosTccLoginShellMock,
|
||||
resolveAgentForegroundProcessMock,
|
||||
readWindowsPtyJobProcessIdsMock,
|
||||
killWithDescendantSweepMock,
|
||||
isWslAvailableAsyncMock,
|
||||
wslUncDirectoryExistsAsyncMock,
|
||||
createShellPromptReadinessProbeMock
|
||||
} = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
accessSyncMock: vi.fn(),
|
||||
mkdirSyncMock: vi.fn(),
|
||||
writeFileSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
prepareMacosTccLoginShellMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn(),
|
||||
readWindowsPtyJobProcessIdsMock: vi.fn(),
|
||||
killWithDescendantSweepMock: vi.fn(),
|
||||
isWslAvailableAsyncMock: vi.fn(),
|
||||
wslUncDirectoryExistsAsyncMock: vi.fn(),
|
||||
createShellPromptReadinessProbeMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslPathOwner>()),
|
||||
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
|
||||
toWindowsWslPath: (path: string, distro: string) =>
|
||||
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
statSync: statSyncMock,
|
||||
accessSync: accessSyncMock,
|
||||
mkdirSync: mkdirSyncMock,
|
||||
writeFileSync: writeFileSyncMock,
|
||||
chmodSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
constants: { X_OK: 1 }
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/tmp/orca-user-data')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MacosTccLoginShell>()),
|
||||
prepareMacosTccLoginShell: prepareMacosTccLoginShellMock
|
||||
}))
|
||||
|
||||
vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({
|
||||
killWithDescendantSweep: killWithDescendantSweepMock
|
||||
}))
|
||||
|
||||
// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests.
|
||||
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
vi.mock('../windows-powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe'
|
||||
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
|
||||
: [WINDOWS_POWERSHELL_ABS, CMD_ABS],
|
||||
getWindowsCmdPath: () => CMD_ABS
|
||||
}))
|
||||
|
||||
vi.mock('../agent-foreground-process', () => ({
|
||||
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
|
||||
resolveAgentForegroundProcessMock(...args)
|
||||
}))
|
||||
|
||||
vi.mock('../windows-pty-job-membership', () => ({
|
||||
readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args),
|
||||
isWindowsPtyJobReadable: () => true
|
||||
}))
|
||||
|
||||
vi.mock('../../wsl/filesystem/unc-path-access', () => ({
|
||||
parseWslPath: (path: string) => {
|
||||
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
distro: match[1],
|
||||
linuxPath: (match[2] || '').replace(/\\/g, '/') || '/'
|
||||
}
|
||||
},
|
||||
wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args)
|
||||
}))
|
||||
vi.mock('../../wsl/discovery/distro-catalog', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu'
|
||||
}))
|
||||
|
||||
vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslAvailabilityTypes>()),
|
||||
isWslAvailableAsync: () => isWslAvailableAsyncMock()
|
||||
}))
|
||||
|
||||
vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
|
||||
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
|
||||
}))
|
||||
|
||||
import { LocalPtyProvider } from './local-pty-provider'
|
||||
import {
|
||||
applyLocalPtyProviderMockDefaults,
|
||||
createLocalPtyMockProcess,
|
||||
installLocalPtyProviderEnvSandbox,
|
||||
type LocalPtyMockProcess
|
||||
} from './local-pty-provider-test-harness'
|
||||
import { isRootLikePath } from '../pty-path-safety'
|
||||
|
||||
describe('LocalPtyProvider', () => {
|
||||
let provider: LocalPtyProvider
|
||||
let mockProc: LocalPtyMockProcess
|
||||
let exitCb: ((info: { exitCode: number }) => void) | undefined
|
||||
|
||||
installLocalPtyProviderEnvSandbox()
|
||||
|
||||
beforeEach(() => {
|
||||
applyLocalPtyProviderMockDefaults({
|
||||
existsSyncMock,
|
||||
statSyncMock,
|
||||
accessSyncMock,
|
||||
mkdirSyncMock,
|
||||
writeFileSyncMock,
|
||||
prepareMacosTccLoginShellMock,
|
||||
resolveAgentForegroundProcessMock,
|
||||
readWindowsPtyJobProcessIdsMock,
|
||||
killWithDescendantSweepMock,
|
||||
isWslAvailableAsyncMock,
|
||||
wslUncDirectoryExistsAsyncMock,
|
||||
createShellPromptReadinessProbeMock
|
||||
})
|
||||
|
||||
exitCb = undefined
|
||||
mockProc = createLocalPtyMockProcess({
|
||||
get: () => exitCb,
|
||||
set: (cb) => {
|
||||
exitCb = cb
|
||||
}
|
||||
})
|
||||
spawnMock.mockReturnValue(mockProc)
|
||||
|
||||
provider = new LocalPtyProvider()
|
||||
})
|
||||
|
||||
describe('spawn', () => {
|
||||
it('throws when cwd does not exist', async () => {
|
||||
existsSyncMock.mockImplementation((p: string) => p !== '/nonexistent')
|
||||
await expect(provider.spawn({ cols: 80, rows: 24, cwd: '/nonexistent' })).rejects.toThrow(
|
||||
'does not exist'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows an explicitly requested plain shell at POSIX root', async () => {
|
||||
await provider.spawn({ cols: 80, rows: 24, cwd: '/' })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ cwd: '/' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the safe default cwd for automatic agent startup without an explicit cwd', async () => {
|
||||
spawnMock.mockClear()
|
||||
const origHome = process.env.HOME
|
||||
// Pin the exact default-home candidate to catch unintended cwd resolution.
|
||||
process.env.HOME = '/home/testuser'
|
||||
|
||||
try {
|
||||
// Omitted cwd resolves to safe home; the root-path guard must still allow startup.
|
||||
await expect(
|
||||
provider.spawn({ cols: 80, rows: 24, command: 'codex' })
|
||||
).resolves.toBeDefined()
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].cwd).toBe('/home/testuser')
|
||||
expect(isRootLikePath(spawnCall[2].cwd)).toBe(false)
|
||||
} finally {
|
||||
if (origHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = origHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects automatic agent startup at POSIX root', async () => {
|
||||
spawnMock.mockClear()
|
||||
|
||||
await expect(
|
||||
provider.spawn({ cols: 80, rows: 24, cwd: '/', command: 'claude' })
|
||||
).rejects.toThrow(/requires a non-root workspace/)
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const originalUserProfile = process.env.USERPROFILE
|
||||
const originalHomeDrive = process.env.HOMEDRIVE
|
||||
const originalHomePath = process.env.HOMEPATH
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
delete process.env.USERPROFILE
|
||||
process.env.HOMEDRIVE = 'D:'
|
||||
process.env.HOMEPATH = '\\Users\\orca'
|
||||
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
if (originalUserProfile === undefined) {
|
||||
delete process.env.USERPROFILE
|
||||
} else {
|
||||
process.env.USERPROFILE = originalUserProfile
|
||||
}
|
||||
if (originalHomeDrive === undefined) {
|
||||
delete process.env.HOMEDRIVE
|
||||
} else {
|
||||
process.env.HOMEDRIVE = originalHomeDrive
|
||||
}
|
||||
if (originalHomePath === undefined) {
|
||||
delete process.env.HOMEPATH
|
||||
} else {
|
||||
process.env.HOMEPATH = originalHomePath
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ cwd: 'D:\\Users\\orca' })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,655 +0,0 @@
|
||||
import type * as WslPathOwner from '../../../shared/wsl-paths'
|
||||
import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { delimiter } from 'node:path'
|
||||
import type * as MacosTccLoginShell from '../macos-tcc-login-shell'
|
||||
import { stripLegacyTerminalShimEnv } from '../../pty/shell-startup/legacy-terminal-shim-dir'
|
||||
|
||||
const {
|
||||
existsSyncMock,
|
||||
statSyncMock,
|
||||
accessSyncMock,
|
||||
mkdirSyncMock,
|
||||
writeFileSyncMock,
|
||||
spawnMock,
|
||||
prepareMacosTccLoginShellMock,
|
||||
resolveAgentForegroundProcessMock,
|
||||
readWindowsPtyJobProcessIdsMock,
|
||||
killWithDescendantSweepMock,
|
||||
isWslAvailableAsyncMock,
|
||||
wslUncDirectoryExistsAsyncMock,
|
||||
createShellPromptReadinessProbeMock
|
||||
} = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
accessSyncMock: vi.fn(),
|
||||
mkdirSyncMock: vi.fn(),
|
||||
writeFileSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
prepareMacosTccLoginShellMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn(),
|
||||
readWindowsPtyJobProcessIdsMock: vi.fn(),
|
||||
killWithDescendantSweepMock: vi.fn(),
|
||||
isWslAvailableAsyncMock: vi.fn(),
|
||||
wslUncDirectoryExistsAsyncMock: vi.fn(),
|
||||
createShellPromptReadinessProbeMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslPathOwner>()),
|
||||
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
|
||||
toWindowsWslPath: (path: string, distro: string) =>
|
||||
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
statSync: statSyncMock,
|
||||
accessSync: accessSyncMock,
|
||||
mkdirSync: mkdirSyncMock,
|
||||
writeFileSync: writeFileSyncMock,
|
||||
chmodSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
constants: { X_OK: 1 }
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/tmp/orca-user-data')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MacosTccLoginShell>()),
|
||||
prepareMacosTccLoginShell: prepareMacosTccLoginShellMock
|
||||
}))
|
||||
|
||||
vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({
|
||||
killWithDescendantSweep: killWithDescendantSweepMock
|
||||
}))
|
||||
|
||||
// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests.
|
||||
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
vi.mock('../windows-powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe'
|
||||
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
|
||||
: [WINDOWS_POWERSHELL_ABS, CMD_ABS],
|
||||
getWindowsCmdPath: () => CMD_ABS
|
||||
}))
|
||||
|
||||
vi.mock('../agent-foreground-process', () => ({
|
||||
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
|
||||
resolveAgentForegroundProcessMock(...args)
|
||||
}))
|
||||
|
||||
vi.mock('../windows-pty-job-membership', () => ({
|
||||
readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args),
|
||||
isWindowsPtyJobReadable: () => true
|
||||
}))
|
||||
|
||||
vi.mock('../../wsl/filesystem/unc-path-access', () => ({
|
||||
parseWslPath: (path: string) => {
|
||||
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
distro: match[1],
|
||||
linuxPath: (match[2] || '').replace(/\\/g, '/') || '/'
|
||||
}
|
||||
},
|
||||
wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args)
|
||||
}))
|
||||
vi.mock('../../wsl/discovery/distro-catalog', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu'
|
||||
}))
|
||||
|
||||
vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslAvailabilityTypes>()),
|
||||
isWslAvailableAsync: () => isWslAvailableAsyncMock()
|
||||
}))
|
||||
|
||||
vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
|
||||
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
|
||||
}))
|
||||
|
||||
import { LocalPtyProvider } from './local-pty-provider'
|
||||
import {
|
||||
applyLocalPtyProviderMockDefaults,
|
||||
createLocalPtyMockProcess,
|
||||
installLocalPtyProviderEnvSandbox,
|
||||
type LocalPtyMockProcess
|
||||
} from './local-pty-provider-test-harness'
|
||||
|
||||
describe('LocalPtyProvider', () => {
|
||||
let provider: LocalPtyProvider
|
||||
let mockProc: LocalPtyMockProcess
|
||||
let exitCb: ((info: { exitCode: number }) => void) | undefined
|
||||
|
||||
installLocalPtyProviderEnvSandbox()
|
||||
|
||||
beforeEach(() => {
|
||||
applyLocalPtyProviderMockDefaults({
|
||||
existsSyncMock,
|
||||
statSyncMock,
|
||||
accessSyncMock,
|
||||
mkdirSyncMock,
|
||||
writeFileSyncMock,
|
||||
prepareMacosTccLoginShellMock,
|
||||
resolveAgentForegroundProcessMock,
|
||||
readWindowsPtyJobProcessIdsMock,
|
||||
killWithDescendantSweepMock,
|
||||
isWslAvailableAsyncMock,
|
||||
wslUncDirectoryExistsAsyncMock,
|
||||
createShellPromptReadinessProbeMock
|
||||
})
|
||||
|
||||
exitCb = undefined
|
||||
mockProc = createLocalPtyMockProcess({
|
||||
get: () => exitCb,
|
||||
set: (cb) => {
|
||||
exitCb = cb
|
||||
}
|
||||
})
|
||||
spawnMock.mockReturnValue(mockProc)
|
||||
|
||||
provider = new LocalPtyProvider()
|
||||
})
|
||||
|
||||
describe('spawn', () => {
|
||||
it('expands variables in PATH before spawning a Windows shell', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo',
|
||||
env: {
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
|
||||
ORCA_PATH_ROOT: 'C:\\Users\\orca\\AppData\\Local',
|
||||
PATH: '%orca_path_root%\\agy\\bin;C:\\Windows'
|
||||
}
|
||||
})
|
||||
|
||||
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe(
|
||||
'C:\\Users\\orca\\AppData\\Local\\agy\\bin;C:\\Windows'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes explicit pane environment separately from inherited process values', async () => {
|
||||
const buildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => env)
|
||||
provider.configure({ buildSpawnEnv })
|
||||
const env = { XDG_DATA_HOME: '/pane/data' }
|
||||
await provider.spawn({ cols: 80, rows: 24, env })
|
||||
expect(buildSpawnEnv).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ explicitEnv: env })
|
||||
)
|
||||
})
|
||||
|
||||
it('invokes buildSpawnEnv callback to customize environment', async () => {
|
||||
const buildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => {
|
||||
env.CUSTOM_VAR = 'custom-value'
|
||||
return env
|
||||
})
|
||||
provider.configure({ buildSpawnEnv })
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
|
||||
})
|
||||
|
||||
it('re-reads buildSpawnEnv after a reentrant configuration change', async () => {
|
||||
const initialBuildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => env)
|
||||
const configuredBuildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => env)
|
||||
provider.configure({
|
||||
get buildSpawnEnv() {
|
||||
provider.configure({ buildSpawnEnv: configuredBuildSpawnEnv })
|
||||
return initialBuildSpawnEnv
|
||||
}
|
||||
})
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
expect(initialBuildSpawnEnv).not.toHaveBeenCalled()
|
||||
expect(configuredBuildSpawnEnv).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
// Exported fish_history can leak the launching workspace session even when isolation is off (STA-4682).
|
||||
['an inherited Orca session', 'orca_abc123', undefined],
|
||||
['a user value', 'mine', 'mine']
|
||||
])('history isolation off: %s', async (_kind, inherited, expected) => {
|
||||
const previous = process.env.fish_history
|
||||
process.env.fish_history = inherited
|
||||
try {
|
||||
// Missing workspace id exercises history-off cleanup.
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.fish_history
|
||||
} else {
|
||||
process.env.fish_history = previous
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock.mock.calls.at(-1)![2].env.fish_history).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
// Exported HISTFILE can leak the launching workspace history even with isolation off.
|
||||
[
|
||||
'an inherited Orca path',
|
||||
'/fake/userData/terminal-history/aabbccddeeff0011/zsh_history',
|
||||
undefined
|
||||
],
|
||||
['a user value', '/home/me/.zsh_history', '/home/me/.zsh_history']
|
||||
])('history isolation off: %s HISTFILE', async (_kind, inherited, expected) => {
|
||||
const previous = process.env.HISTFILE
|
||||
process.env.HISTFILE = inherited
|
||||
try {
|
||||
// Missing workspace id exercises history-off cleanup.
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.HISTFILE
|
||||
} else {
|
||||
process.env.HISTFILE = previous
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock.mock.calls.at(-1)![2].env.HISTFILE).toBe(expected)
|
||||
})
|
||||
|
||||
it('does not inherit NODE_ENV from the Orca process env', async () => {
|
||||
// Ambient NODE_ENV belongs to the Orca build and can break terminal next build/Vitest.
|
||||
const previous = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = previous
|
||||
}
|
||||
}
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.NODE_ENV).toBeUndefined()
|
||||
const expectedEnv = { PATH: process.env.PATH ?? '' }
|
||||
stripLegacyTerminalShimEnv(expectedEnv, process.platform)
|
||||
expect(spawnCall[2].env.PATH).toBe(expectedEnv.PATH)
|
||||
})
|
||||
|
||||
it('keeps an explicitly requested NODE_ENV for spawned terminals', async () => {
|
||||
const previous = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24, env: { NODE_ENV: 'production' } })
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = previous
|
||||
}
|
||||
}
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.NODE_ENV).toBe('production')
|
||||
})
|
||||
|
||||
it('suppresses the first-run Powerlevel10k wizard for spawned terminals', async () => {
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBe('true')
|
||||
})
|
||||
|
||||
it('preserves an explicit Powerlevel10k wizard env value', async () => {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: { POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'already-set' }
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBe('already-set')
|
||||
})
|
||||
|
||||
it('honors requests to delete the Powerlevel10k wizard env value', async () => {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
envToDelete: ['POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD']
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBeUndefined()
|
||||
})
|
||||
|
||||
it('honors explicit terminal env overrides after deleting requested defaults', async () => {
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => {
|
||||
env.TERM_PROGRAM = 'Orca'
|
||||
env.ORCA_STALE_TEST_ENV = '/tmp/orca-stale'
|
||||
env.PATH = `/tmp/orca-stale:${env.PATH ?? ''}`
|
||||
return env
|
||||
}
|
||||
})
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {
|
||||
TERM: 'screen-256color',
|
||||
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
|
||||
},
|
||||
envToDelete: ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV']
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].name).toBe('screen-256color')
|
||||
expect(spawnCall[2].env.TERM).toBe('screen-256color')
|
||||
expect(spawnCall[2].env.PATH.split(':')[0]).toBe('/tmp/orca-agent-teams-bin')
|
||||
expect(spawnCall[2].env.TERM_PROGRAM).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_STALE_TEST_ENV).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not re-promote a legacy attribution path for Agent Teams', async () => {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {
|
||||
PATH: '/tmp/orca-terminal-attribution/posix:/usr/bin',
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
|
||||
}
|
||||
})
|
||||
|
||||
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('drops stale inherited Git config indices behind a smaller explicit count', async () => {
|
||||
const keys = [
|
||||
'GIT_CONFIG_COUNT',
|
||||
'GIT_CONFIG_KEY_0',
|
||||
'GIT_CONFIG_VALUE_0',
|
||||
'GIT_CONFIG_KEY_1',
|
||||
'GIT_CONFIG_VALUE_1'
|
||||
] as const
|
||||
const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
|
||||
process.env.GIT_CONFIG_COUNT = '2'
|
||||
process.env.GIT_CONFIG_KEY_0 = 'base.zero'
|
||||
process.env.GIT_CONFIG_VALUE_0 = 'zero'
|
||||
process.env.GIT_CONFIG_KEY_1 = 'base.one'
|
||||
process.env.GIT_CONFIG_VALUE_1 = 'one'
|
||||
|
||||
try {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'override.zero',
|
||||
GIT_CONFIG_VALUE_0: 'override'
|
||||
}
|
||||
})
|
||||
|
||||
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
|
||||
expect(spawnEnv.GIT_CONFIG_COUNT).toBe('1')
|
||||
expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('override.zero')
|
||||
expect(spawnEnv.GIT_CONFIG_KEY_1).toBeUndefined()
|
||||
expect(spawnEnv.GIT_CONFIG_VALUE_1).toBeUndefined()
|
||||
} finally {
|
||||
for (const key of keys) {
|
||||
if (saved[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = saved[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not inherit AppImage runtime env into Linux PTY shells', async () => {
|
||||
const saved = {
|
||||
APPIMAGE: process.env.APPIMAGE,
|
||||
APPDIR: process.env.APPDIR,
|
||||
ARGV0: process.env.ARGV0,
|
||||
OWD: process.env.OWD,
|
||||
APPIMAGE_LIBRARY_PATH: process.env.APPIMAGE_LIBRARY_PATH,
|
||||
PATH: process.env.PATH,
|
||||
LD_LIBRARY_PATH: process.env.LD_LIBRARY_PATH
|
||||
}
|
||||
process.env.APPIMAGE = '/data/apps/orca.appimage'
|
||||
process.env.APPDIR = '/tmp/.mount_orca123'
|
||||
process.env.ARGV0 = '/data/apps/orca.appimage'
|
||||
process.env.OWD = '/home/user/project'
|
||||
process.env.APPIMAGE_LIBRARY_PATH = '/tmp/.mount_orca123/usr/lib'
|
||||
process.env.PATH = ['/tmp/.mount_orca123', '/tmp/.mount_orca123/usr/sbin', '/usr/bin'].join(
|
||||
delimiter
|
||||
)
|
||||
process.env.LD_LIBRARY_PATH = ['/tmp/.mount_orca123/usr/lib', '/opt/audio/lib'].join(
|
||||
delimiter
|
||||
)
|
||||
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(env.APPIMAGE).toBeUndefined()
|
||||
expect(env.APPDIR).toBeUndefined()
|
||||
expect(env.ARGV0).toBeUndefined()
|
||||
expect(env.OWD).toBeUndefined()
|
||||
expect(env.APPIMAGE_LIBRARY_PATH).toBeUndefined()
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
expect(env.LD_LIBRARY_PATH).toBe('/opt/audio/lib')
|
||||
})
|
||||
|
||||
it('does not forward a half-activated conda env into the shell', async () => {
|
||||
// CONDA_SHLVL without CONDA_PREFIX breaks user conda hooks (#14195).
|
||||
const saved = {
|
||||
CONDA_SHLVL: process.env.CONDA_SHLVL,
|
||||
CONDA_PREFIX: process.env.CONDA_PREFIX,
|
||||
CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV,
|
||||
CONDA_PROMPT_MODIFIER: process.env.CONDA_PROMPT_MODIFIER,
|
||||
CONDA_EXE: process.env.CONDA_EXE
|
||||
}
|
||||
delete process.env.CONDA_PREFIX
|
||||
process.env.CONDA_SHLVL = '1'
|
||||
process.env.CONDA_DEFAULT_ENV = 'base'
|
||||
process.env.CONDA_PROMPT_MODIFIER = '(base) '
|
||||
process.env.CONDA_EXE = '/opt/miniconda3/bin/conda'
|
||||
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(env.CONDA_SHLVL).toBeUndefined()
|
||||
expect(env.CONDA_DEFAULT_ENV).toBeUndefined()
|
||||
expect(env.CONDA_PROMPT_MODIFIER).toBeUndefined()
|
||||
expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda')
|
||||
})
|
||||
|
||||
it('drops the conda sentinel when the client asks to delete CONDA_PREFIX', async () => {
|
||||
// envToDelete runs after the scrub, so coherence repair must run last.
|
||||
const saved = {
|
||||
CONDA_SHLVL: process.env.CONDA_SHLVL,
|
||||
CONDA_PREFIX: process.env.CONDA_PREFIX,
|
||||
CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV
|
||||
}
|
||||
process.env.CONDA_SHLVL = '1'
|
||||
process.env.CONDA_PREFIX = '/opt/miniconda3'
|
||||
process.env.CONDA_DEFAULT_ENV = 'base'
|
||||
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24, envToDelete: ['CONDA_PREFIX'] })
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(env.CONDA_PREFIX).toBeUndefined()
|
||||
expect(env.CONDA_SHLVL).toBeUndefined()
|
||||
expect(env.CONDA_DEFAULT_ENV).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses shell wrapper when MiMo home must survive shell startup', async () => {
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => {
|
||||
env.MIMOCODE_CONFIG_DIR = '/tmp/orca-mimocode-overlay'
|
||||
env.ORCA_MIMOCODE_CONFIG_DIR = '/tmp/orca-mimocode-overlay'
|
||||
return env
|
||||
}
|
||||
})
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[1]).toEqual(['-l'])
|
||||
expect(spawnCall[2].env.ZDOTDIR).toMatch(/shell-ready[\\/]zsh/)
|
||||
expect(spawnCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready')
|
||||
})
|
||||
|
||||
it('promotes the agent-teams shim onto the Windows `Path` spelling', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => {
|
||||
// Why: host env collapses Windows PATH onto `Path` and prepends its own shim dir.
|
||||
delete env.PATH
|
||||
env.Path = `/tmp/orca-stale:${env.Path ?? ''}`
|
||||
return env
|
||||
}
|
||||
})
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {
|
||||
Path: '/tmp/orca-agent-teams-bin:/usr/bin',
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
|
||||
}
|
||||
})
|
||||
|
||||
const spawnEnv = spawnMock.mock.calls.at(-1)![2].env
|
||||
expect(Object.keys(spawnEnv).filter((key) => /^path$/i.test(key))).toEqual(['Path'])
|
||||
expect(spawnEnv.Path.split(':')[0]).toBe('/tmp/orca-agent-teams-bin')
|
||||
})
|
||||
|
||||
it('does not inherit parent Orca pane identity when caller omits pane env', async () => {
|
||||
const saved = {
|
||||
ORCA_PANE_KEY: process.env.ORCA_PANE_KEY,
|
||||
ORCA_TAB_ID: process.env.ORCA_TAB_ID,
|
||||
ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID,
|
||||
ORCA_ORCAD_MANAGED_ACTIVATION_ROOT: process.env.ORCA_ORCAD_MANAGED_ACTIVATION_ROOT,
|
||||
ORCA_REMOTE_VIEWER_CLIPBOARD: process.env.ORCA_REMOTE_VIEWER_CLIPBOARD,
|
||||
ORCA_ORCAD_IDLE_TIMEOUT_MS: process.env.ORCA_ORCAD_IDLE_TIMEOUT_MS,
|
||||
ORCA_WSL_ORCAD_HOST: process.env.ORCA_WSL_ORCAD_HOST,
|
||||
ORCA_WSL_DISTRO_GUID: process.env.ORCA_WSL_DISTRO_GUID
|
||||
}
|
||||
process.env.ORCA_ORCAD_MANAGED_ACTIVATION_ROOT = '/home/u/.orca-remote/.activation'
|
||||
process.env.ORCA_ORCAD_IDLE_TIMEOUT_MS = '300000'
|
||||
// A WSL host's own marker and distro id; a user's `orcad` in its shell is not that host.
|
||||
process.env.ORCA_WSL_ORCAD_HOST = '1'
|
||||
process.env.ORCA_WSL_DISTRO_GUID = '{0e3a7f2c-1b4d-4c5e-9f60-7a8b9c0d1e2f}'
|
||||
process.env.ORCA_REMOTE_VIEWER_CLIPBOARD = '1'
|
||||
process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf'
|
||||
process.env.ORCA_TAB_ID = 'parent-tab'
|
||||
process.env.ORCA_WORKTREE_ID = 'parent-worktree'
|
||||
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.ORCA_PANE_KEY).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_TAB_ID).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_WORKTREE_ID).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_ORCAD_MANAGED_ACTIVATION_ROOT).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_REMOTE_VIEWER_CLIPBOARD).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_ORCAD_IDLE_TIMEOUT_MS).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_WSL_ORCAD_HOST).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_WSL_DISTRO_GUID).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves explicit child Orca pane identity over parent env', async () => {
|
||||
const saved = {
|
||||
ORCA_PANE_KEY: process.env.ORCA_PANE_KEY,
|
||||
ORCA_TAB_ID: process.env.ORCA_TAB_ID,
|
||||
ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID
|
||||
}
|
||||
process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf'
|
||||
process.env.ORCA_TAB_ID = 'parent-tab'
|
||||
process.env.ORCA_WORKTREE_ID = 'parent-worktree'
|
||||
|
||||
try {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {
|
||||
ORCA_PANE_KEY: 'child-tab:child-leaf',
|
||||
ORCA_TAB_ID: 'child-tab',
|
||||
ORCA_WORKTREE_ID: 'child-worktree'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.ORCA_PANE_KEY).toBe('child-tab:child-leaf')
|
||||
expect(spawnCall[2].env.ORCA_TAB_ID).toBe('child-tab')
|
||||
expect(spawnCall[2].env.ORCA_WORKTREE_ID).toBe('child-worktree')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -133,7 +133,6 @@ vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
|
||||
|
||||
import { LocalPtyProvider } from './local-pty-provider'
|
||||
import {
|
||||
pendingLocalPtySpawns,
|
||||
ptyDisposables,
|
||||
ptyExitDisposables,
|
||||
ptyPhysicalExits,
|
||||
@@ -218,180 +217,6 @@ describe('LocalPtyProvider', () => {
|
||||
expect(mockProc.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reattaches to an existing caller-supplied session id without spawning', async () => {
|
||||
const first = await provider.spawn({ cols: 80, rows: 24, sessionId: 'serve-session-1' })
|
||||
spawnMock.mockClear()
|
||||
|
||||
const second = await provider.spawn({ cols: 120, rows: 40, sessionId: first.id })
|
||||
|
||||
expect(second).toEqual({
|
||||
id: 'serve-session-1',
|
||||
incarnationId: first.incarnationId,
|
||||
pid: 12345,
|
||||
isReattach: true,
|
||||
// This local reattach resized the PTY; publish the new grid unlike daemon/relay attachments.
|
||||
attachedGrid: { cols: 120, rows: 40 }
|
||||
})
|
||||
expect(mockProc.resize).toHaveBeenCalledWith(120, 40)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches only to an existing stable session', async () => {
|
||||
await provider.spawn({ cols: 80, rows: 24, sessionId: 'stable-pane-session' })
|
||||
spawnMock.mockClear()
|
||||
|
||||
const result = await provider.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
sessionId: 'stable-pane-session',
|
||||
attachOnly: true
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ id: 'stable-pane-session', isReattach: true })
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not create when an attach-only stable session is absent', async () => {
|
||||
await expect(
|
||||
provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'missing-stable-pane-session',
|
||||
attachOnly: true
|
||||
})
|
||||
).rejects.toThrow('Session not found: missing-stable-pane-session')
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches only to an existing numeric provider session', async () => {
|
||||
const first = await provider.spawn({ cols: 80, rows: 24 })
|
||||
spawnMock.mockClear()
|
||||
|
||||
const result = await provider.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
sessionId: first.id,
|
||||
attachOnly: true
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: first.id,
|
||||
incarnationId: first.incarnationId,
|
||||
isReattach: true
|
||||
})
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not create when a numeric attach-only provider session is absent', async () => {
|
||||
await expect(
|
||||
provider.spawn({ cols: 80, rows: 24, sessionId: '404', attachOnly: true })
|
||||
).rejects.toThrow('Session not found: 404')
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a native UNC session native on a conflicting WSL reattach', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const first = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'native-session',
|
||||
cwd: '\\\\server\\share\\repo',
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
spawnMock.mockClear()
|
||||
|
||||
const second = await provider.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
sessionId: first.id,
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Ubuntu'
|
||||
})
|
||||
|
||||
expect(first.wslDistro).toBeNull()
|
||||
expect(second.wslDistro).toBeNull()
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the first WSL distro on a conflicting distro reattach', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const first = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'wsl-session',
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
})
|
||||
spawnMock.mockClear()
|
||||
|
||||
const second = await provider.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
sessionId: first.id,
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
})
|
||||
|
||||
expect(first.wslDistro).toBe('Ubuntu')
|
||||
expect(second.wslDistro).toBe('Ubuntu')
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reattach numeric caller session ids that can collide after restart', async () => {
|
||||
const first = await provider.spawn({ cols: 80, rows: 24 })
|
||||
spawnMock.mockClear()
|
||||
|
||||
const second = await provider.spawn({ cols: 120, rows: 40, sessionId: first.id })
|
||||
|
||||
expect(second.id).not.toBe(first.id)
|
||||
expect(second.isReattach).toBeUndefined()
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not spawn after shutdown cancels a pending stable session id', async () => {
|
||||
let finishPreparation!: () => void
|
||||
spawnMock.mockClear()
|
||||
prepareMacosTccLoginShellMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishPreparation = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const spawn = provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'pending-local-session'
|
||||
})
|
||||
const canceledSpawn = expect(spawn).rejects.toThrow(
|
||||
'PTY spawn canceled: pending-local-session'
|
||||
)
|
||||
await vi.waitFor(() => expect(prepareMacosTccLoginShellMock).toHaveBeenCalledOnce())
|
||||
|
||||
await provider.shutdown('pending-local-session', { immediate: true })
|
||||
finishPreparation()
|
||||
await canceledSpawn
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
|
||||
await expect(
|
||||
provider.spawn({ cols: 80, rows: 24, sessionId: 'pending-local-session' })
|
||||
).resolves.toMatchObject({ id: 'pending-local-session' })
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels an immediate stable-session shutdown before any PTY is spawned', async () => {
|
||||
spawnMock.mockClear()
|
||||
|
||||
const spawn = provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'immediate-shutdown-session'
|
||||
})
|
||||
await provider.shutdown('immediate-shutdown-session', { immediate: true })
|
||||
|
||||
await expect(spawn).rejects.toThrow('PTY spawn canceled: immediate-shutdown-session')
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Hook/trust setup makes env build a long pre-spawn await; shutdown must cancel during it (#16441).
|
||||
it('does not spawn after shutdown cancels a pending spawn during the env build', async () => {
|
||||
spawnMock.mockClear()
|
||||
@@ -451,117 +276,6 @@ describe('LocalPtyProvider', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each([1, 2])(
|
||||
'settles final-preflight shutdown without a late PTY (%i microtasks)',
|
||||
async (microtasks) => {
|
||||
spawnMock.mockClear()
|
||||
let finishPreparation!: () => void
|
||||
prepareMacosTccLoginShellMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishPreparation = resolve
|
||||
})
|
||||
)
|
||||
const id = 'preflight-resume-session'
|
||||
const kill = mockProc.kill
|
||||
let committed = false
|
||||
const outcome = provider
|
||||
.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: id,
|
||||
onPtySpawnCommitted: () => {
|
||||
committed = true
|
||||
}
|
||||
})
|
||||
.then(
|
||||
(result) => ({ ok: true as const, result }),
|
||||
(error: unknown) => ({ ok: false as const, error })
|
||||
)
|
||||
await import('node-pty')
|
||||
await vi.waitFor(() => expect(finishPreparation).toBeTypeOf('function'))
|
||||
finishPreparation()
|
||||
let committedAtShutdown = false
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const shutdown = () => {
|
||||
committedAtShutdown = committed
|
||||
provider.shutdown(id, { immediate: true }).then(resolve, reject)
|
||||
}
|
||||
queueMicrotask(() => (microtasks === 1 ? shutdown() : queueMicrotask(shutdown)))
|
||||
})
|
||||
const settled = await outcome
|
||||
if (committedAtShutdown) {
|
||||
expect(settled.ok).toBe(true)
|
||||
expect(kill).toHaveBeenCalled()
|
||||
} else {
|
||||
expect(settled).toEqual({ ok: false, error: new Error(`PTY spawn canceled: ${id}`) })
|
||||
expect(spawnMock.mock.calls.length).toBe(0)
|
||||
}
|
||||
expect(provider.getPtyProcess(id)).toBeUndefined()
|
||||
expect(pendingLocalPtySpawns.has(id)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('cancels deferred shell availability before finalizing a launch plan', async () => {
|
||||
spawnMock.mockClear()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
let finishAvailability!: (available: boolean) => void
|
||||
const buildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => env)
|
||||
provider.configure({
|
||||
getWindowsShell: () => 'powershell.exe',
|
||||
getWindowsPowerShellImplementation: () => 'auto',
|
||||
pwshAvailable: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
finishAvailability = resolve
|
||||
}),
|
||||
buildSpawnEnv
|
||||
})
|
||||
const id = 'deferred-availability-session'
|
||||
const spawn = provider.spawn({ cols: 80, rows: 24, sessionId: id })
|
||||
const rejected = expect(spawn).rejects.toThrow(`PTY spawn canceled: ${id}`)
|
||||
await provider.shutdown(id, { immediate: true })
|
||||
finishAvailability(true)
|
||||
await rejected
|
||||
expect(buildSpawnEnv).not.toHaveBeenCalled()
|
||||
expect(spawnMock.mock.calls.length).toBe(0)
|
||||
expect(pendingLocalPtySpawns.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['synchronous environment', 'async environment', 'preflight'])(
|
||||
'releases cancellation state after failed %s and allows a fresh retry',
|
||||
async (phase) => {
|
||||
spawnMock.mockClear()
|
||||
const id = 'failed-preparation-session'
|
||||
let fail = true
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => {
|
||||
if (fail && phase === 'synchronous environment') {
|
||||
throw new Error('preparation failed')
|
||||
}
|
||||
if (fail && phase === 'async environment') {
|
||||
return Promise.reject(new Error('preparation failed'))
|
||||
}
|
||||
return env
|
||||
}
|
||||
})
|
||||
if (phase === 'preflight') {
|
||||
prepareMacosTccLoginShellMock.mockRejectedValueOnce(new Error('preparation failed'))
|
||||
}
|
||||
await expect(provider.spawn({ cols: 80, rows: 24, sessionId: id })).rejects.toThrow(
|
||||
'preparation failed'
|
||||
)
|
||||
expect(pendingLocalPtySpawns.has(id)).toBe(false)
|
||||
expect(spawnMock.mock.calls.length).toBe(0)
|
||||
await provider.shutdown(id, { immediate: true })
|
||||
fail = false
|
||||
await expect(provider.spawn({ cols: 80, rows: 24, sessionId: id })).resolves.toMatchObject({
|
||||
id
|
||||
})
|
||||
expect(spawnMock.mock.calls.length).toBe(1)
|
||||
expect(pendingLocalPtySpawns.has(id)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('coalesces a concurrent same-session-id spawn before launching a redundant shell (F3)', async () => {
|
||||
spawnMock.mockClear()
|
||||
const procA = { ...mockProc, pid: 1001 }
|
||||
@@ -588,28 +302,5 @@ describe('LocalPtyProvider', () => {
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
expect(procA.resize).toHaveBeenCalledWith(132, 44)
|
||||
})
|
||||
|
||||
it('invokes onSpawned callback', async () => {
|
||||
const onSpawned = vi.fn()
|
||||
provider.configure({ onSpawned })
|
||||
const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
expect(onSpawned).toHaveBeenCalledWith(id, incarnationId)
|
||||
})
|
||||
|
||||
it('reports physical commit before post-spawn publication can fail', async () => {
|
||||
spawnMock.mockClear()
|
||||
const committed = vi.fn()
|
||||
provider.configure({
|
||||
onSpawned: () => {
|
||||
throw new Error('post-spawn publication failed')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
provider.spawn({ cols: 80, rows: 24, onPtySpawnCommitted: committed })
|
||||
).rejects.toThrow('post-spawn publication failed')
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
expect(committed).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,7 +81,6 @@ vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({
|
||||
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe'
|
||||
vi.mock('../windows-powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
@@ -130,8 +129,6 @@ import {
|
||||
installLocalPtyProviderEnvSandbox,
|
||||
type LocalPtyMockProcess
|
||||
} from './local-pty-provider-test-harness'
|
||||
import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../../pty/shell-startup/powerlevel10k-wizard-env'
|
||||
import { wslDistroHoldMessage } from '../../../shared/wsl-distro-stopped-hold'
|
||||
|
||||
describe('LocalPtyProvider', () => {
|
||||
let provider: LocalPtyProvider
|
||||
@@ -169,242 +166,6 @@ describe('LocalPtyProvider', () => {
|
||||
})
|
||||
|
||||
describe('spawn', () => {
|
||||
it('passes the guest Claude pointer and injected-home marker through WSLENV', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => ({
|
||||
...env,
|
||||
CLAUDE_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home',
|
||||
ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home',
|
||||
ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca'
|
||||
})
|
||||
})
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo'
|
||||
})
|
||||
const env = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(env.WSLENV.split(':')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
'ORCA_CLAUDE_PROFILE_POINTER',
|
||||
'ORCA_CLAUDE_INJECTED_CONFIG_DIR'
|
||||
])
|
||||
)
|
||||
expect(env.ORCA_CLAUDE_PROFILE_POINTER).toBe(
|
||||
'~/.local/share/orca/claude-profiles/selected-wsl-orca'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not pass a Windows Codex home into WSL terminals', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => {
|
||||
env.CODEX_HOME = 'C:\\Users\\jin\\.codex'
|
||||
env.ORCA_CODEX_HOME = 'C:\\Users\\jin\\.codex'
|
||||
return env
|
||||
}
|
||||
})
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[2].env.CODEX_HOME).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not pass a WSL managed Codex home into Windows terminals', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => {
|
||||
env.CODEX_HOME =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
|
||||
env.ORCA_CODEX_HOME =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
|
||||
return env
|
||||
}
|
||||
})
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\Users\\jin\\repo'
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.CODEX_HOME).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves an explicit Linux Codex home for WSL terminals', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => {
|
||||
env.CODEX_HOME = '/home/jin/.codex-alt'
|
||||
return env
|
||||
}
|
||||
})
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.codex-alt')
|
||||
expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME')
|
||||
})
|
||||
|
||||
it('translates a WSL managed Codex home before launching a WSL terminal', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => {
|
||||
env.CODEX_HOME =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
|
||||
env.ORCA_CODEX_HOME =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
|
||||
return env
|
||||
}
|
||||
})
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.local/share/orca/codex-accounts/a/home')
|
||||
expect(spawnCall[2].env.ORCA_CODEX_HOME).toBe(
|
||||
'/home/jin/.local/share/orca/codex-accounts/a/home'
|
||||
)
|
||||
expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME')
|
||||
expect(spawnCall[2].env.WSLENV).toContain('ORCA_CODEX_HOME')
|
||||
})
|
||||
|
||||
it('does not pass a WSL managed Codex home into a different WSL distro', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env) => {
|
||||
env.CODEX_HOME =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
|
||||
env.ORCA_CODEX_HOME =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
|
||||
return env
|
||||
}
|
||||
})
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Debian\\home\\jin\\repo'
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[2].env.CODEX_HOME).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the preferred WSL distro for Windows cwd WSL terminals', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[1]).toEqual([
|
||||
'-d',
|
||||
'Debian',
|
||||
'--exec',
|
||||
'sh',
|
||||
'-c',
|
||||
expect.stringContaining("cd '/mnt/c/Users/jin/repo'")
|
||||
])
|
||||
expect(spawnCall[1][5]).toContain('exec "$_orca_wsl_shell" -l')
|
||||
expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian')
|
||||
})
|
||||
|
||||
it.each(['/home/jin/repo', '/a', '/c'])(
|
||||
'preserves a POSIX cwd through the preferred WSL distro (%s)',
|
||||
async (cwd) => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
|
||||
cwd,
|
||||
prevalidatedCwd: `\\\\wsl.localhost\\Debian${cwd.replaceAll('/', '\\')}`,
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[1]).toEqual([
|
||||
'-d',
|
||||
'Debian',
|
||||
'--exec',
|
||||
'sh',
|
||||
'-c',
|
||||
expect.stringContaining(`cd '${cwd}'`)
|
||||
])
|
||||
expect(spawnCall[2].cwd).not.toBe(cwd)
|
||||
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('revalidates when cwd evidence does not exactly match the resolved WSL path', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
|
||||
cwd: '/a',
|
||||
prevalidatedCwd: '\\\\wsl.localhost\\Debian\\c',
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
})
|
||||
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith('\\\\wsl.localhost\\Debian\\a')
|
||||
})
|
||||
|
||||
it('rejects a WSL cwd the distro reports missing without spawning a shell', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
|
||||
const spawnCallsBefore = spawnMock.mock.calls.length
|
||||
|
||||
await expect(
|
||||
provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Debian\\home\\jin\\gone'
|
||||
})
|
||||
).rejects.toThrow(/does not exist/)
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(
|
||||
'\\\\wsl.localhost\\Debian\\home\\jin\\gone'
|
||||
)
|
||||
expect(spawnMock.mock.calls).toHaveLength(spawnCallsBefore)
|
||||
})
|
||||
|
||||
it('rejects a canceled spawn while its WSL cwd probe is still pending', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const probeStarted = Promise.withResolvers<void>()
|
||||
@@ -429,396 +190,5 @@ describe('LocalPtyProvider', () => {
|
||||
await canceledSpawn
|
||||
expect(spawnMock.mock.calls).toHaveLength(spawnCallsBefore)
|
||||
})
|
||||
|
||||
it('resolves and persists the default distro for Windows cwd WSL terminals', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const buildSpawnEnv = vi.fn(
|
||||
(
|
||||
_id: string,
|
||||
env: Record<string, string>,
|
||||
_ctx?: { isWsl?: boolean; wslDistro?: string | null }
|
||||
) => env
|
||||
)
|
||||
provider.configure({ buildSpawnEnv })
|
||||
|
||||
const result = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: null
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[1]).toEqual([
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--exec',
|
||||
'sh',
|
||||
'-c',
|
||||
expect.stringContaining("cd '/mnt/c/Users/jin/repo'")
|
||||
])
|
||||
expect(buildSpawnEnv.mock.calls[0]?.[2]).toMatchObject({
|
||||
isWsl: true,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(result.wslDistro).toBe('Ubuntu')
|
||||
expect(
|
||||
(await provider.listProcesses()).find((entry) => entry.id === result.id)?.wslDistro
|
||||
).toBe('Ubuntu')
|
||||
})
|
||||
|
||||
it('repro: keeps explicit PowerShell 7 selection when the pwsh probe is cold-false', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const pwshAvailable = vi.fn(() => false)
|
||||
provider.configure({
|
||||
getWindowsShell: () => 'powershell.exe',
|
||||
getWindowsPowerShellImplementation: () => 'pwsh.exe',
|
||||
pwshAvailable
|
||||
})
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\Users\\jin\\repo'
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe(PWSH7_ABS)
|
||||
expect(spawnCall[1]).toContain('-EncodedCommand')
|
||||
expect(pwshAvailable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('awaits PowerShell availability before resolving an automatic Windows shell', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
let resolveAvailability!: (available: boolean) => void
|
||||
const pwshAvailable = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveAvailability = resolve
|
||||
})
|
||||
)
|
||||
provider.configure({
|
||||
getWindowsShell: () => 'powershell.exe',
|
||||
getWindowsPowerShellImplementation: () => 'auto',
|
||||
pwshAvailable
|
||||
})
|
||||
|
||||
const callsBeforeSpawn = spawnMock.mock.calls.length
|
||||
const spawn = provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' })
|
||||
await Promise.resolve()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn)
|
||||
|
||||
resolveAvailability(true)
|
||||
await spawn
|
||||
expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn + 1)
|
||||
expect(spawnMock.mock.calls.at(-1)?.[0]).toBe(PWSH7_ABS)
|
||||
})
|
||||
|
||||
it('re-reads Windows shell options after a reentrant configuration change', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const initialPowerShellImplementation = vi.fn(() => 'powershell.exe' as const)
|
||||
const configuredPwshAvailable = vi.fn(() => true)
|
||||
provider.configure({
|
||||
getWindowsShell: () => {
|
||||
provider.configure({
|
||||
getWindowsPowerShellImplementation: () => 'auto',
|
||||
pwshAvailable: configuredPwshAvailable
|
||||
})
|
||||
return 'powershell.exe'
|
||||
},
|
||||
getWindowsPowerShellImplementation: initialPowerShellImplementation
|
||||
})
|
||||
|
||||
await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' })
|
||||
|
||||
expect(initialPowerShellImplementation).not.toHaveBeenCalled()
|
||||
expect(configuredPwshAvailable).toHaveBeenCalledOnce()
|
||||
expect(spawnMock.mock.calls.at(-1)?.[0]).toBe(PWSH7_ABS)
|
||||
})
|
||||
|
||||
it('registers a probe-complete spawn before a later microtask shutdown', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
provider.configure({
|
||||
getWindowsShell: () => 'powershell.exe',
|
||||
getWindowsPowerShellImplementation: () => 'auto',
|
||||
pwshAvailable: () => false
|
||||
})
|
||||
const callsBeforeSpawn = spawnMock.mock.calls.length
|
||||
const spawn = provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
sessionId: 'probe-shutdown-session'
|
||||
})
|
||||
const canceledSpawn = expect(spawn).rejects.toThrow(
|
||||
'PTY spawn canceled: probe-shutdown-session'
|
||||
)
|
||||
const shutdown = new Promise<void>((resolve, reject) => {
|
||||
queueMicrotask(() => {
|
||||
provider.shutdown('probe-shutdown-session', { immediate: true }).then(resolve, reject)
|
||||
})
|
||||
})
|
||||
|
||||
await shutdown
|
||||
await canceledSpawn
|
||||
expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn)
|
||||
})
|
||||
|
||||
it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const savedCodexHome = process.env.CODEX_HOME
|
||||
const savedOrcaCodexHome = process.env.ORCA_CODEX_HOME
|
||||
delete process.env.CODEX_HOME
|
||||
delete process.env.ORCA_CODEX_HOME
|
||||
provider.configure({
|
||||
buildSpawnEnv: (_id, env, ctx) => {
|
||||
env.ORCA_TERMINAL_HANDLE = 'term_wsl'
|
||||
if (ctx?.isWsl) {
|
||||
env.WSLENV = 'ORCA_TERMINAL_HANDLE/u'
|
||||
}
|
||||
return env
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
|
||||
env: { ORCA_HERMES_STARTUP_QUERY: 'line one\nline two' }
|
||||
})
|
||||
} finally {
|
||||
if (savedCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME
|
||||
} else {
|
||||
process.env.CODEX_HOME = savedCodexHome
|
||||
}
|
||||
if (savedOrcaCodexHome === undefined) {
|
||||
delete process.env.ORCA_CODEX_HOME
|
||||
} else {
|
||||
process.env.ORCA_CODEX_HOME = savedOrcaCodexHome
|
||||
}
|
||||
}
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
|
||||
expect(spawnCall[2].env.WSLENV?.split(':')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ORCA_TERMINAL_HANDLE/u',
|
||||
'ORCA_HERMES_STARTUP_QUERY',
|
||||
POWERLEVEL10K_WIZARD_DISABLE_ENV
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('does not mark deleted Powerlevel10k wizard env for WSL import', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
|
||||
envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV]
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[2].env[POWERLEVEL10K_WIZARD_DISABLE_ENV]).toBeUndefined()
|
||||
expect(spawnCall[2].env.WSLENV ?? '').not.toContain(POWERLEVEL10K_WIZARD_DISABLE_ENV)
|
||||
})
|
||||
|
||||
it('launches POSIX cwd split panes through WSL when worktree context is WSL', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
|
||||
try {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/jin/repo/subdir',
|
||||
shellOverride: 'powershell.exe',
|
||||
worktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--exec',
|
||||
'sh',
|
||||
'-c',
|
||||
expect.stringContaining("cd '/home/jin/repo/subdir'")
|
||||
],
|
||||
expect.objectContaining({ cwd: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves the Git Bash default shell and preserves the requested cwd', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const originalProgramFiles = process.env.ProgramFiles
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
process.env.ProgramFiles = 'C:\\Program Files'
|
||||
provider.configure({
|
||||
getWindowsShell: () => 'git-bash',
|
||||
buildSpawnEnv: (_id, env) => ({
|
||||
...env,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\Users\\jin\\repo'
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
if (originalProgramFiles === undefined) {
|
||||
delete process.env.ProgramFiles
|
||||
} else {
|
||||
process.env.ProgramFiles = originalProgramFiles
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
[
|
||||
'-c',
|
||||
expect.stringMatching(
|
||||
/^chcp\.com 65001 >\/dev\/null 2>&1; exec "\$BASH" --rcfile '.*shell-ready\/bash\/rcfile' -i$/
|
||||
)
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
env: expect.objectContaining({
|
||||
CHERE_INVOKING: '1',
|
||||
PYTHONUTF8: '1',
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('runs the Codex preflight once in the cmd.exe startup chain', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
provider.configure({
|
||||
getWindowsShell: () => 'cmd.exe',
|
||||
buildSpawnEnv: (_id, env) => ({
|
||||
...env,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' })
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
[
|
||||
'/K',
|
||||
'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE: '"'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('holds a daemon-off passive restore of a stopped WSL distro without spawning wsl.exe', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
readWslDistroRunningStateMock.mockResolvedValue('stopped')
|
||||
spawnMock.mockClear()
|
||||
|
||||
await expect(
|
||||
provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo',
|
||||
sessionId: 'restored-session',
|
||||
wslPassiveRestore: true
|
||||
})
|
||||
).rejects.toThrow(wslDistroHoldMessage('Ubuntu', 'stopped'))
|
||||
expect(readWslDistroRunningStateMock).toHaveBeenCalledWith('Ubuntu', {
|
||||
retryUnverifiable: true
|
||||
})
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores a daemon-off WSL pane once its distro reads running', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
readWslDistroRunningStateMock.mockResolvedValue('running')
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo',
|
||||
sessionId: 'restored-session',
|
||||
wslPassiveRestore: true
|
||||
})
|
||||
expect(spawnMock.mock.calls.at(-1)?.[0]).toBe('wsl.exe')
|
||||
})
|
||||
|
||||
// A UNC cwd runs in the guest whatever shell was picked, so the cwd alone names the distro.
|
||||
describe('a non-WSL shell whose cwd is a WSL UNC path', () => {
|
||||
const uncSpawn = (cwd: string, extra: { wslPassiveRestore?: true } = {}) =>
|
||||
provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd,
|
||||
sessionId: 'wt-1::C:/folder@@pane1',
|
||||
shellOverride: 'powershell.exe',
|
||||
...extra
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
readWslDistroRunningStateMock.mockReset()
|
||||
readWslDistroRunningStateMock.mockResolvedValue('stopped')
|
||||
spawnMock.mockClear()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo'],
|
||||
['\\\\wsl$\\Ubuntu\\home\\fake\\repo']
|
||||
])('holds its passive restore while the distro is stopped (%s)', async (cwd) => {
|
||||
await expect(uncSpawn(cwd, { wslPassiveRestore: true })).rejects.toThrow(
|
||||
wslDistroHoldMessage('Ubuntu', 'stopped')
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("starts the user's own launch without reading the distro", async () => {
|
||||
await uncSpawn('\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo')
|
||||
expect(spawnMock.mock.calls.at(-1)?.[0]).toBe('wsl.exe')
|
||||
expect(readWslDistroRunningStateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a host cwd alone', async () => {
|
||||
await uncSpawn('C:\\folder', { wslPassiveRestore: true })
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(readWslDistroRunningStateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,91 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
describePosix,
|
||||
importFreshLocalPtyShellReady,
|
||||
restoreUserDataPathAfterEach,
|
||||
setTestUserDataPath
|
||||
} from './local-pty-shell-ready-test-harness'
|
||||
import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile'
|
||||
import { getZshShellReadyWrapperFile } from './local-pty-shell-ready-wrapper-generation'
|
||||
import { makeUserZdotdir } from '../../pty/shell-startup/zsh-user-config-dir-fixture'
|
||||
// Wrapper roots are content-addressed; resolve them instead of assuming a fixed path.
|
||||
import {
|
||||
getRequiredShellReadyWrapperPaths,
|
||||
getShellReadyWrapperRoot
|
||||
} from './local-pty-shell-ready-wrapper-root'
|
||||
import { buildLocalShellReadyWrapperFiles } from './local-pty-shell-ready-wrapper-fileset'
|
||||
|
||||
restoreUserDataPathAfterEach()
|
||||
|
||||
describe('ensureShellReadyWrappersAt', () => {
|
||||
it('keeps required wrapper paths aligned with generated files', () => {
|
||||
const root = '/tmp/orca-shell-ready'
|
||||
expect(getRequiredShellReadyWrapperPaths(root)).toEqual(
|
||||
buildLocalShellReadyWrapperFiles(root).map(([path]) => path)
|
||||
)
|
||||
})
|
||||
|
||||
// Rewriting content-addressed live wrappers adds Windows indexer/antivirus write collisions without changing bytes.
|
||||
it('does not rewrite a tree that is already present', async () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'orca-warm-tree-'))
|
||||
try {
|
||||
setTestUserDataPath(userData)
|
||||
const generation = await import('./local-pty-shell-ready-wrapper-generation')
|
||||
const { getShellReadyWrapperRoot: resolveRoot } =
|
||||
await import('./local-pty-shell-ready-wrapper-root')
|
||||
expect(generation.ensureShellReadyWrappersAt()).toBe(true)
|
||||
const rcfile = join(resolveRoot(), 'bash', 'rcfile')
|
||||
const firstWrite = statSync(rcfile).mtimeMs
|
||||
|
||||
generation.ensureShellReadyWrappersAt()
|
||||
vi.resetModules()
|
||||
const fresh = await import('./local-pty-shell-ready-wrapper-generation')
|
||||
fresh.ensureShellReadyWrappersAt()
|
||||
|
||||
expect(statSync(rcfile).mtimeMs).toBe(firstWrite)
|
||||
} finally {
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('regenerates a tree whose files went missing', async () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'orca-missing-tree-'))
|
||||
try {
|
||||
setTestUserDataPath(userData)
|
||||
const generation = await import('./local-pty-shell-ready-wrapper-generation')
|
||||
const { getShellReadyWrapperRoot: resolveRoot } =
|
||||
await import('./local-pty-shell-ready-wrapper-root')
|
||||
generation.ensureShellReadyWrappersAt()
|
||||
const rcfile = join(resolveRoot(), 'bash', 'rcfile')
|
||||
rmSync(rcfile)
|
||||
|
||||
expect(generation.ensureShellReadyWrappersAt()).toBe(true)
|
||||
expect(existsSync(rcfile)).toBe(true)
|
||||
} finally {
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('shell-ready wrapper root resolution', () => {
|
||||
// Plain-node daemon forks have no Electron app; use the shared user-data env path.
|
||||
it('resolves the wrapper root from ORCA_USER_DATA_PATH', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-userdata-env-'))
|
||||
try {
|
||||
setTestUserDataPath(root)
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
expect(config.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh'))
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
|
||||
const itWithBash = hasBash ? it : it.skip
|
||||
|
||||
@@ -164,180 +90,6 @@ describePosix('local PTY shell-ready launch config', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('wraps fish launches with a fish_prompt shell-ready marker init command', async () => {
|
||||
// Fish must wait through its first prompt or agent launch can race Starship initialization (STA-3417).
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
const config = getShellReadyLaunchConfig('/opt/homebrew/bin/fish')
|
||||
|
||||
expect(config.supportsReadyMarker).toBe(true)
|
||||
// Fish bakes features into init text without an exported feature variable.
|
||||
expect(config.env).toEqual({})
|
||||
expect(config.args?.slice(0, 2)).toEqual(['-l', '-C'])
|
||||
const init = config.args?.[2] ?? ''
|
||||
expect(init).toContain('--on-event fish_prompt')
|
||||
// Why `builtin`: a user-defined printf function would swallow the marker.
|
||||
expect(init).toContain('builtin printf "\\033]777;orca-shell-ready\\007"')
|
||||
expect(init).toContain('functions -e __orca_shell_ready_marker')
|
||||
})
|
||||
|
||||
it('keeps markerless fish argv untouched and points it at the vendor snippet', async () => {
|
||||
const { getMarkerlessShellLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
const config = getMarkerlessShellLaunchConfig('/opt/homebrew/bin/fish')
|
||||
|
||||
expect(config.args).toBeNull()
|
||||
expect(config.supportsReadyMarker).toBe(false)
|
||||
const prefix = config.env.ORCA_FISH_XDG_DATA_DIRS_PREFIX
|
||||
expect(prefix).toMatch(/\/fish-xdg-data:\/usr\/local\/share:\/usr\/share$/)
|
||||
expect(config.env).toEqual({ XDG_DATA_DIRS: prefix, ORCA_FISH_XDG_DATA_DIRS_PREFIX: prefix })
|
||||
const dataDir = prefix.split(':')[0]
|
||||
expect(
|
||||
readFileSync(`${dataDir}/fish/vendor_conf.d/orca-shell-integration.fish`, 'utf8')
|
||||
).toContain('set argv --no-daemon $argv')
|
||||
})
|
||||
|
||||
it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => {
|
||||
// Nested Orca launches must not inherit wrapper ZDOTDIR and recurse.
|
||||
const previousZdotdir = process.env.ZDOTDIR
|
||||
const previousHome = process.env.HOME
|
||||
process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh'
|
||||
process.env.HOME = '/Users/alice'
|
||||
try {
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined()
|
||||
} finally {
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR
|
||||
} else {
|
||||
process.env.ZDOTDIR = previousZdotdir
|
||||
}
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = previousHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to HOME when inherited ORCA_ORIG_ZDOTDIR points at a wrapper dir', async () => {
|
||||
const previousZdotdir = process.env.ZDOTDIR
|
||||
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
|
||||
const previousHome = process.env.HOME
|
||||
delete process.env.ZDOTDIR
|
||||
process.env.ORCA_ORIG_ZDOTDIR = '/some/other/orca/shell-ready/zsh'
|
||||
process.env.HOME = '/Users/alice'
|
||||
try {
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined()
|
||||
} finally {
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR
|
||||
} else {
|
||||
process.env.ZDOTDIR = previousZdotdir
|
||||
}
|
||||
if (previousOrigZdotdir === undefined) {
|
||||
delete process.env.ORCA_ORIG_ZDOTDIR
|
||||
} else {
|
||||
process.env.ORCA_ORIG_ZDOTDIR = previousOrigZdotdir
|
||||
}
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = previousHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('writes a zsh hook that hands ZDOTDIR back before any user file loads', async () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
getShellReadyLaunchConfig('/bin/zsh')
|
||||
|
||||
const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
|
||||
expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"')
|
||||
expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
|
||||
// Clear handback state before nested Orca or /etc/zshrc can mistake it for user configuration.
|
||||
expect(zshenv).toContain('builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR')
|
||||
expect(zshenv.indexOf('builtin export ZDOTDIR=')).toBeLessThan(
|
||||
zshenv.indexOf('builtin source -- "$_orca_user_zshenv"')
|
||||
)
|
||||
// Restored user ZDOTDIR owns the remaining startup files.
|
||||
for (const name of ['.zprofile', '.zshrc', '.zlogin']) {
|
||||
expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', name))).toBe(false)
|
||||
}
|
||||
expect(zshenv).not.toContain('$(emulate')
|
||||
})
|
||||
|
||||
it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
getShellReadyLaunchConfig('/bin/zsh')
|
||||
|
||||
// .zshenv defines the deferred registration run once by first-prompt precmd.
|
||||
const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
|
||||
expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark')
|
||||
expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"')
|
||||
expect(zshenv).toContain('printf "\\033]777;orca-shell-ready\\007"')
|
||||
// Why: add-zle-hook-widget aborts its chain on a non-zero earlier hook (e.g. oh-my-zsh vi-mode); don't register the marker through it.
|
||||
expect(zshenv).not.toContain('add-zle-hook-widget line-init')
|
||||
// Re-sourcing must preserve the original widget chain instead of capturing Orca itself.
|
||||
expect(zshenv).toContain('== "user:__orca_prompt_mark"')
|
||||
expect(zshenv).toContain('__orca_deferred_init')
|
||||
})
|
||||
|
||||
it('writes wrappers without restoring Pi/OMP homes after user startup files', async () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
getShellReadyLaunchConfig('/bin/zsh')
|
||||
|
||||
// .zshrc and .zlogin both invoke the single restore epilogue defined in .zshenv.
|
||||
const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
|
||||
const bashRc = getBashShellReadyRcfileContent()
|
||||
const restoreLine =
|
||||
'[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
|
||||
const mimoRestoreLine =
|
||||
'[[ -n "${ORCA_MIMOCODE_CONFIG_DIR:-}" ]] && export MIMOCODE_CONFIG_DIR="${ORCA_MIMOCODE_CONFIG_DIR}"'
|
||||
const codexRestoreLine =
|
||||
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
|
||||
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
|
||||
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
|
||||
expect(zshrc).toContain(restoreLine)
|
||||
expect(bashRc).toContain(restoreLine)
|
||||
expect(zshrc).toContain(mimoRestoreLine)
|
||||
expect(bashRc).toContain(mimoRestoreLine)
|
||||
expect(zshrc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
|
||||
expect(bashRc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
|
||||
expect(zshrc).toContain(codexRestoreLine)
|
||||
expect(zshrc).toContain(agentTeamsPathRestoreLine)
|
||||
expect(bashRc).toContain(agentTeamsPathRestoreLine)
|
||||
expect(bashRc).toContain(codexRestoreLine)
|
||||
expect(zshrc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
|
||||
expect(bashRc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
|
||||
expect(zshrc).toContain(ompWrapperLine)
|
||||
expect(bashRc).toContain(ompWrapperLine)
|
||||
for (const wrapperFile of [zshrc, bashRc]) {
|
||||
expect(wrapperFile).not.toContain('prime-agent()')
|
||||
expect(wrapperFile).not.toContain('__orca_prime_agent')
|
||||
expect(wrapperFile).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION')
|
||||
expect(wrapperFile).not.toContain('command prime-agent --extension')
|
||||
}
|
||||
})
|
||||
|
||||
it('emits OSC 133 C/D markers in the zsh wrapper', () => {
|
||||
const zshRc = getZshShellReadyWrapperFile()
|
||||
expect(zshRc).toContain('printf "\\033]133;D;%s\\007"')
|
||||
expect(zshRc).toContain('printf "\\033]133;C\\007"')
|
||||
})
|
||||
|
||||
itWithBash('runs the bash wrapper without fake C/D markers before the first prompt', async () => {
|
||||
const output = runInteractiveBashRcfile(getBashShellReadyRcfileContent(), userDataPath)
|
||||
|
||||
expectBashOsc133Lifecycle(output)
|
||||
})
|
||||
|
||||
itWithBash('emits lifecycle for foreground text ending like an internal hook', () => {
|
||||
const input = 'echo user:__orca_osc133_prompt_done\nfalse\nexit 0\n'
|
||||
const output = runInteractiveBashRcfile(getBashShellReadyRcfileContent(), userDataPath, input)
|
||||
@@ -398,27 +150,6 @@ describePosix('local PTY shell-ready launch config', () => {
|
||||
expectBashOsc133Lifecycle(output)
|
||||
})
|
||||
|
||||
itWithBash(
|
||||
'starts the next command after its prompt when the DEBUG trap is removed at a legacy prompt',
|
||||
() => {
|
||||
// Why: force the bash <= 5.0 scalar PROMPT_COMMAND dispatch on any bash.
|
||||
const gate = 'BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1)'
|
||||
const rcfile = getBashShellReadyRcfileContent()
|
||||
expect(rcfile).toContain(gate)
|
||||
const legacyRcfile = rcfile.replaceAll(gate, '0')
|
||||
writeFileSync(join(userDataPath, '.bash_profile'), "PS1='ORCA_PS1> '\n")
|
||||
const input = 'trap - DEBUG\nfalse\nexit 0\n'
|
||||
const output = runInteractiveBashRcfile(legacyRcfile, userDataPath, input)
|
||||
const startBeforePrompt = new RegExp(
|
||||
`${String.fromCharCode(27)}]133;A${String.fromCharCode(7)}(?:(?!ORCA_PS1> )[\\s\\S])*${String.fromCharCode(27)}]133;C`
|
||||
)
|
||||
|
||||
expect(output).toContain('ORCA_PS1> ')
|
||||
expect(output).not.toMatch(startBeforePrompt)
|
||||
expectBashOsc133Lifecycle(output)
|
||||
}
|
||||
)
|
||||
|
||||
itWithBash('composes an inherited PROMPT_COMMAND ending in separators', () => {
|
||||
writeFileSync(
|
||||
join(userDataPath, '.bash_profile'),
|
||||
@@ -431,88 +162,4 @@ describePosix('local PTY shell-ready launch config', () => {
|
||||
expect(output).toContain('PROMPT_SEPARATOR')
|
||||
expectBashOsc133Lifecycle(output)
|
||||
})
|
||||
|
||||
it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => {
|
||||
const previousZdotdir = process.env.ZDOTDIR
|
||||
const userZdotdir = makeUserZdotdir(userDataPath, '.config', 'zsh')
|
||||
process.env.ZDOTDIR = userZdotdir
|
||||
try {
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir)
|
||||
} finally {
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR
|
||||
} else {
|
||||
process.env.ZDOTDIR = previousZdotdir
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to HOME when ZDOTDIR is only slashes (e.g. "/")', async () => {
|
||||
const previousZdotdir = process.env.ZDOTDIR
|
||||
const previousHome = process.env.HOME
|
||||
process.env.ZDOTDIR = '/'
|
||||
process.env.HOME = '/Users/alice'
|
||||
try {
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined()
|
||||
} finally {
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR
|
||||
} else {
|
||||
process.env.ZDOTDIR = previousZdotdir
|
||||
}
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = previousHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves ZDOTDIR that contains /shell-ready/zsh as a substring but does not end with it', async () => {
|
||||
const previousZdotdir = process.env.ZDOTDIR
|
||||
const userZdotdir = makeUserZdotdir(userDataPath, 'shell-ready', 'zsh-custom')
|
||||
process.env.ZDOTDIR = userZdotdir
|
||||
try {
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir)
|
||||
} finally {
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR
|
||||
} else {
|
||||
process.env.ZDOTDIR = previousZdotdir
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('sources the user .zshenv at wrapper top level, not inside a function', async () => {
|
||||
// Function-scoped .zshenv broke typeset -U path; user configuration must execute at top level (#1737).
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
getShellReadyLaunchConfig('/bin/zsh')
|
||||
|
||||
const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
|
||||
|
||||
expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"')
|
||||
// Define required functions before user emulate sh can change parsing semantics.
|
||||
expect(zshenv.indexOf('__orca_deferred_init() {')).toBeLessThan(
|
||||
zshenv.indexOf('builtin source -- "$_orca_user_zshenv"')
|
||||
)
|
||||
})
|
||||
|
||||
it('bakes no generation-time path into the zsh hook', async () => {
|
||||
// Windows-generated wrappers may be sourced through /mnt/c in WSL; generation-time paths cannot be embedded (#8003).
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
getShellReadyLaunchConfig('/bin/zsh')
|
||||
|
||||
const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
|
||||
|
||||
expect(zshenv).not.toContain(getShellReadyWrapperRoot())
|
||||
expect(zshenv).not.toContain('%x')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import type * as LoginSessionPtyProbe from './macos-login-session-pty-probe'
|
||||
|
||||
const { existsSyncMock, userInfoMock, runProcessMock, ptyProbeMock } = vi.hoisted(() => ({
|
||||
@@ -35,469 +34,9 @@ const ACCEPTED_RESULT = {
|
||||
timedOut: false
|
||||
}
|
||||
const REJECTED_RESULT = { code: 1, signal: null, stdout: '', stderr: '', timedOut: false }
|
||||
const TIMED_OUT_RESULT = {
|
||||
code: null,
|
||||
signal: 'SIGKILL' as const,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: true
|
||||
}
|
||||
const ACCEPTED_OUTCOME = { ok: true, conclusive: true, reason: 'accepted' } as const
|
||||
const REJECTED_OUTCOME = { ok: false, conclusive: true, reason: 'rejected' } as const
|
||||
const itOnPosixHost = process.platform === 'win32' ? it.skip : it
|
||||
|
||||
describe('wrapShellSpawnForMacosTccAttribution', () => {
|
||||
let origPlatform: PropertyDescriptor | undefined
|
||||
let origDisable: string | undefined
|
||||
|
||||
function setPlatform(value: string): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
origPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
origDisable = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
userInfoMock.mockReturnValue({ username: 'ada', homedir: '/Users/ada' })
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: 'ORCA_LOGIN_PREFLIGHT_OK',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
|
||||
resetMacosLoginShellPreflightForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (origPlatform) {
|
||||
Object.defineProperty(process, 'platform', origPlatform)
|
||||
}
|
||||
if (origDisable === undefined) {
|
||||
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
} else {
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = origDisable
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses the exact positional login-shell trampoline argv on macOS', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args: [
|
||||
'-flpq',
|
||||
'ada',
|
||||
'/bin/bash',
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-p',
|
||||
'-c',
|
||||
'export SHELL="$1"; shift; exec -l -- "$@"',
|
||||
'orca-tcc-login',
|
||||
'/bin/zsh',
|
||||
'/bin/zsh',
|
||||
'-l'
|
||||
]
|
||||
})
|
||||
expect(runProcessMock).toHaveBeenCalledWith({
|
||||
program: '/usr/bin/login',
|
||||
args: ['-flpq', 'ada', '/usr/bin/printf', 'ORCA_LOGIN_PREFLIGHT_OK'],
|
||||
// Detached daemons can outlive their worktree; probe from account home.
|
||||
cwd: '/Users/ada',
|
||||
maxOutputBytes: 1024,
|
||||
signal: undefined,
|
||||
timeoutMs: 500
|
||||
})
|
||||
})
|
||||
|
||||
it('spawns the shell directly when login PAM policy rejects the current user', async () => {
|
||||
setPlatform('darwin')
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: 'Login incorrect\nlogin: ',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'[pty] macOS login(1) preflight failed; spawning shells directly'
|
||||
)
|
||||
})
|
||||
|
||||
it('caches a conclusive PAM rejection for later terminal spawns', async () => {
|
||||
setPlatform('darwin')
|
||||
runProcessMock.mockImplementation(() => {
|
||||
// login(1) exiting non-zero on its own is a deterministic PAM verdict.
|
||||
return REJECTED_RESULT
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await prepareMacosTccLoginShell()
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/bash', ['-l']).file).toBe('/bin/bash')
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(1)
|
||||
expect(ptyProbeMock).toHaveBeenCalledTimes(1)
|
||||
expect(console.warn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses the production-shaped PTY verdict when the pipe probe falsely rejects', async () => {
|
||||
setPlatform('darwin')
|
||||
runProcessMock.mockReturnValue(REJECTED_RESULT)
|
||||
ptyProbeMock.mockResolvedValue(ACCEPTED_OUTCOME)
|
||||
|
||||
await expect(prepareMacosTccLoginShell()).resolves.toEqual(ACCEPTED_OUTCOME)
|
||||
|
||||
expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 500, 1_024)
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('dedupes concurrent PTY confirmations of a rejected pipe verdict', async () => {
|
||||
setPlatform('darwin')
|
||||
runProcessMock.mockReturnValue(REJECTED_RESULT)
|
||||
let finishPtyProbe!: (outcome: typeof REJECTED_OUTCOME) => void
|
||||
ptyProbeMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finishPtyProbe = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const first = prepareMacosTccLoginShell()
|
||||
const second = prepareMacosTccLoginShell()
|
||||
// Wait for probe sharing rather than coupling the assertion to an internal microtask count.
|
||||
await vi.waitFor(() => expect(ptyProbeMock).toHaveBeenCalledTimes(1))
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
finishPtyProbe(REJECTED_OUTCOME)
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
REJECTED_OUTCOME,
|
||||
REJECTED_OUTCOME
|
||||
])
|
||||
})
|
||||
|
||||
it('re-verifies a cached PAM rejection after the revalidation window (#9756)', async () => {
|
||||
setPlatform('darwin')
|
||||
let now = 1_000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
let attempt = 0
|
||||
runProcessMock.mockImplementation(() => {
|
||||
attempt += 1
|
||||
// A one-off PAM hiccup that login(1) reports as a deterministic rejection.
|
||||
return attempt === 1 ? REJECTED_RESULT : ACCEPTED_RESULT
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
|
||||
now += 29 * 60_000
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(1)
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
|
||||
now += 60_000
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(2)
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('keeps an accepted PAM verdict cached across the rejection revalidation window', async () => {
|
||||
setPlatform('darwin')
|
||||
let now = 1_000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
|
||||
await prepareMacosTccLoginShell()
|
||||
now += 24 * 60 * 60_000
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(1)
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('backs off repeated transient timeouts instead of delaying every terminal spawn (F1)', async () => {
|
||||
setPlatform('darwin')
|
||||
let now = 1_000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
// A probe our own SIGKILL cap killed proves nothing about PAM; it must retry.
|
||||
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const first = await prepareMacosTccLoginShell()
|
||||
expect(first).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
|
||||
now += 5_000
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Backoff prevents repeated hung probes from adding a subprocess to every terminal.
|
||||
now += 5_000
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(2)
|
||||
now += 5_000
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('self-heals to the login wrapper once a re-probe succeeds (F1)', async () => {
|
||||
setPlatform('darwin')
|
||||
let now = 1_000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
let attempt = 0
|
||||
runProcessMock.mockImplementation(() => {
|
||||
attempt += 1
|
||||
return attempt === 1 ? TIMED_OUT_RESULT : ACCEPTED_RESULT
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await prepareMacosTccLoginShell()
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
now += 5_000
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(2)
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('returns a conclusive outcome the daemon can log structurally (F2)', async () => {
|
||||
setPlatform('darwin')
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: 'Login incorrect\nlogin: ',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const outcome = await prepareMacosTccLoginShell()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
|
||||
expect(await prepareMacosTccLoginShell()).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a marker emitted by a preflight that does not exit cleanly', async () => {
|
||||
setPlatform('darwin')
|
||||
// A marker printed by a probe that then hit the timeout cap proves nothing.
|
||||
runProcessMock.mockReturnValue({
|
||||
code: null,
|
||||
signal: 'SIGKILL' as const,
|
||||
stdout: 'ORCA_LOGIN_PREFLIGHT_OK',
|
||||
stderr: '',
|
||||
timedOut: true
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('dedupes concurrent successful preflights and caches later terminal spawns', async () => {
|
||||
setPlatform('darwin')
|
||||
|
||||
await Promise.all([prepareMacosTccLoginShell(), prepareMacosTccLoginShell()])
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/bash', ['-l']).file).toBe('/usr/bin/login')
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves the non-login Bash shell-ready rcfile launch', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(
|
||||
wrapShellSpawnForMacosTccAttribution('/bin/bash', ['--rcfile', '/orca/bash/rcfile'])
|
||||
).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args: [
|
||||
'-flpq',
|
||||
'ada',
|
||||
'/bin/bash',
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-p',
|
||||
'-c',
|
||||
'export SHELL="$1"; shift; exec -- "$@"',
|
||||
'orca-tcc-login',
|
||||
'/bin/bash',
|
||||
'/bin/bash',
|
||||
'--rcfile',
|
||||
'/orca/bash/rcfile'
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('re-asserts the spawn env SHELL that login(1) would overwrite', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(
|
||||
wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'], { SHELL: '/opt/homebrew/bin/fish' })
|
||||
).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args: [
|
||||
'-flpq',
|
||||
'ada',
|
||||
'/bin/bash',
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-p',
|
||||
'-c',
|
||||
'export SHELL="$1"; shift; exec -l -- "$@"',
|
||||
'orca-tcc-login',
|
||||
'/opt/homebrew/bin/fish',
|
||||
'/bin/zsh',
|
||||
'-l'
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the spawned shell for SHELL when the env value is empty', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'], { SHELL: '' })).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args: [
|
||||
'-flpq',
|
||||
'ada',
|
||||
'/bin/bash',
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-p',
|
||||
'-c',
|
||||
'export SHELL="$1"; shift; exec -l -- "$@"',
|
||||
'orca-tcc-login',
|
||||
'/bin/zsh',
|
||||
'/bin/zsh',
|
||||
'-l'
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('passes spaces and equals signs positionally without interpolation', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(
|
||||
wrapShellSpawnForMacosTccAttribution(
|
||||
'/Applications/Custom Shell/bin/fish=debug',
|
||||
['--init-command', 'set label=a b'],
|
||||
{ SHELL: '/Applications/Custom Shell/bin/fish=debug' }
|
||||
)
|
||||
).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args: [
|
||||
'-flpq',
|
||||
'ada',
|
||||
'/bin/bash',
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-p',
|
||||
'-c',
|
||||
'export SHELL="$1"; shift; exec -l -- "$@"',
|
||||
'orca-tcc-login',
|
||||
'/Applications/Custom Shell/bin/fish=debug',
|
||||
'/Applications/Custom Shell/bin/fish=debug',
|
||||
'--init-command',
|
||||
'set label=a b'
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('terminates exec option parsing before a dash-prefixed shell name', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
const wrapped = wrapShellSpawnForMacosTccAttribution('-custom-shell', ['-l'])
|
||||
|
||||
expect(wrapped.args).toContain('export SHELL="$1"; shift; exec -l -- "$@"')
|
||||
expect(wrapped.args.slice(-2)).toEqual(['-custom-shell', '-l'])
|
||||
})
|
||||
|
||||
itOnPosixHost('ignores inherited BASH_ENV before evaluating the trampoline', () => {
|
||||
const result = spawnSync(
|
||||
'/bin/bash',
|
||||
['--noprofile', '--norc', '-p', '-c', 'printf "TRAMPOLINE_OK\\n"'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, BASH_ENV: '/dev/stdin' },
|
||||
input: 'printf "BASH_ENV_EXECUTED\\n"\n'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('TRAMPOLINE_OK\n')
|
||||
expect(result.stderr).toBe('')
|
||||
})
|
||||
|
||||
it('is a no-op on non-macOS platforms', () => {
|
||||
setPlatform('linux')
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('is idempotent when the file is already /usr/bin/login', () => {
|
||||
setPlatform('darwin')
|
||||
const args = ['-flpq', 'ada', '/bin/zsh', '-l']
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/usr/bin/login', args)).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the plain spawn when the login binary is missing', () => {
|
||||
setPlatform('darwin')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the plain spawn when the username cannot be resolved', () => {
|
||||
setPlatform('darwin')
|
||||
userInfoMock.mockImplementation(() => {
|
||||
throw new Error('no user')
|
||||
})
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the plain spawn when the username is empty', () => {
|
||||
setPlatform('darwin')
|
||||
userInfoMock.mockReturnValue({ username: '', homedir: '/Users/ada' })
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the plain spawn when disabled via env', () => {
|
||||
setPlatform('darwin')
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
void (process.platform === 'win32' ? it.skip : it)
|
||||
|
||||
describe('probeMacosLoginSessionAlive', () => {
|
||||
let origPlatform: PropertyDescriptor | undefined
|
||||
@@ -537,37 +76,6 @@ describe('probeMacosLoginSessionAlive', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('re-probes even after a cached acceptance', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(1)
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reuses an in-flight startup warmup instead of spawning a duplicate probe', async () => {
|
||||
setPlatform('darwin')
|
||||
let finishPreflight!: (result: typeof ACCEPTED_RESULT) => void
|
||||
runProcessMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishPreflight = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const warmup = prepareMacosTccLoginShell()
|
||||
const freshProbe = probeMacosLoginSessionAlive()
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
|
||||
finishPreflight(ACCEPTED_RESULT)
|
||||
await expect(Promise.all([warmup, freshProbe])).resolves.toEqual([
|
||||
ACCEPTED_OUTCOME,
|
||||
ACCEPTED_OUTCOME
|
||||
])
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not let a spawn-path probe overwrite a newer death verdict', async () => {
|
||||
setPlatform('darwin')
|
||||
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
|
||||
@@ -585,132 +93,4 @@ describe('probeMacosLoginSessionAlive', () => {
|
||||
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
})
|
||||
|
||||
it('flips the spawn wrapper off when a fresh probe conclusively rejects (dead login session)', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
|
||||
runProcessMock.mockReturnValue(REJECTED_RESULT)
|
||||
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
|
||||
// A dead login session must stop creating login(1) prompt zombies (#7936).
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
})
|
||||
|
||||
it('does not trust a pipe rejection when its PTY confirmation is inconclusive', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
runProcessMock.mockReturnValue(REJECTED_RESULT)
|
||||
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
|
||||
await expect(probeMacosLoginSessionAlive()).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'timeout'
|
||||
})
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('does not add PTY probes to periodic checks on a host that never accepted login', async () => {
|
||||
setPlatform('darwin')
|
||||
runProcessMock.mockReturnValue(REJECTED_RESULT)
|
||||
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
|
||||
|
||||
await prepareMacosTccLoginShell()
|
||||
await probeMacosLoginSessionAlive()
|
||||
await probeMacosLoginSessionAlive()
|
||||
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(3)
|
||||
expect(ptyProbeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not let periodic rejected health probes postpone spawn revalidation', async () => {
|
||||
setPlatform('darwin')
|
||||
let now = 1_000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
await prepareMacosTccLoginShell()
|
||||
runProcessMock.mockReturnValue(REJECTED_RESULT)
|
||||
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
|
||||
|
||||
await probeMacosLoginSessionAlive()
|
||||
now += 29 * 60_000
|
||||
await probeMacosLoginSessionAlive()
|
||||
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: 'ORCA_LOGIN_PREFLIGHT_OK',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
now += 60_000
|
||||
await prepareMacosTccLoginShell()
|
||||
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(4)
|
||||
expect(ptyProbeMock).toHaveBeenCalledTimes(2)
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('returns null off macOS and when disabled', async () => {
|
||||
setPlatform('linux')
|
||||
expect(await probeMacosLoginSessionAlive()).toBeNull()
|
||||
setPlatform('darwin')
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
|
||||
expect(await probeMacosLoginSessionAlive()).toBeNull()
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('escalates an inconclusive pipe probe to a PTY probe and accepts its verdict', async () => {
|
||||
setPlatform('darwin')
|
||||
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 4_000, 1_024, undefined)
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('treats a PTY-probe rejection as conclusive and flips the wrapper off', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
|
||||
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: true, reason: 'rejected' })
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
})
|
||||
|
||||
it('stays inconclusive when both pipe and PTY probes time out', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
|
||||
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
// Inconclusive must not disturb the cached acceptance.
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('does not start a PTY fallback after the watch cancels its pipe probe', async () => {
|
||||
setPlatform('darwin')
|
||||
const abortController = new AbortController()
|
||||
abortController.abort()
|
||||
// Cancellation proves no PAM verdict.
|
||||
runProcessMock.mockReturnValue({
|
||||
code: null,
|
||||
signal: 'SIGTERM' as const,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
await expect(probeMacosLoginSessionAlive(abortController.signal)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'error'
|
||||
})
|
||||
expect(ptyProbeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,542 +0,0 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { buildWslInteractiveLoginShellCommand } from '../../shared/wsl-login-shell-command'
|
||||
import { WSL_GUEST_ROOT_MARKER_COMMAND } from '../../shared/wsl-guest-root-marker'
|
||||
import { resolveSetupRunnerCommand } from '../../shared/setup-runner-command'
|
||||
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
|
||||
// Resolve content-addressed wrapper paths instead of assuming a fixed directory.
|
||||
import { getShellReadyWrapperRoot } from './local-pty/local-pty-shell-ready-wrapper-root'
|
||||
|
||||
const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe'
|
||||
const POWERSHELL_STARTUP_COMMAND_C_MARK = '[Console]::Write("$([char]27)]133;C$([char]7)")'
|
||||
const CMD_CODEX_LAUNCH_PREFLIGHT =
|
||||
'if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
|
||||
function expectedWslArgs(linuxCwd: string, distro?: string): string[] {
|
||||
const command = `cd '${linuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && ${WSL_GUEST_ROOT_MARKER_COMMAND} && ${buildWslInteractiveLoginShellCommand()}`
|
||||
// Spell out --exec so a regressed -- separator cannot change both implementation and expected args.
|
||||
const shellArgs = ['--exec', 'sh', '-c', command]
|
||||
return distro ? ['-d', distro, ...shellArgs] : shellArgs
|
||||
}
|
||||
|
||||
function decodePowerShellCommand(result: ReturnType<typeof resolveWindowsShellLaunchArgs>): string {
|
||||
expect(result.shellArgs.slice(0, 3)).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand'])
|
||||
return Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le')
|
||||
}
|
||||
|
||||
function expectedPowerShellRestoreCwdCommand(cwdLiteral: string): string {
|
||||
return `try { Set-Location -LiteralPath ${cwdLiteral} -ErrorAction Stop } catch { Write-Warning "Failed to restore working directory: $_" }`
|
||||
}
|
||||
|
||||
function getGitBashRcfilePath(command: string): string {
|
||||
const rcfilePath = command.match(/--rcfile '([^']+)'/)?.[1]
|
||||
if (!rcfilePath) {
|
||||
throw new Error(`Git Bash launch command has no rcfile: ${command}`)
|
||||
}
|
||||
return rcfilePath
|
||||
}
|
||||
|
||||
describe('resolveWindowsShellLaunchArgs', () => {
|
||||
let previousUserDataPath: string | undefined
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'windows-shell-args-test-'))
|
||||
process.env.ORCA_USER_DATA_PATH = userDataPath
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice')
|
||||
})
|
||||
|
||||
it('embeds short cmd.exe startup commands in shell args', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex --no-alt-screen',
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual([
|
||||
'/K',
|
||||
`chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT} & codex --no-alt-screen`
|
||||
])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps quoted cmd.exe startup commands on stdin delivery', () => {
|
||||
// node-pty C-runtime escaping corrupts cmd /K quotes; stdin preserves normal interactive parsing.
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
'C:\\Users\\alice\\repo',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'cd /d "C:\\Users\\alice\\repo" && claude "--resume" "session one"'
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul'])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps large cmd.exe startup commands on stdin delivery', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
`codex ${'x'.repeat(7000)}`,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns PowerShell args that install OSC 133 bootstrap after normal profile loading', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)])
|
||||
|
||||
const command = decodePowerShellCommand(result)
|
||||
const outputEncodingIndex = command.indexOf('[Console]::OutputEncoding')
|
||||
const opencodeRestoreIndex = command.indexOf(
|
||||
'$env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR'
|
||||
)
|
||||
const mimocodeRestoreIndex = command.indexOf(
|
||||
'$env:MIMOCODE_CONFIG_DIR = $env:ORCA_MIMOCODE_CONFIG_DIR'
|
||||
)
|
||||
const duplicateStateGuardIndex = command.indexOf('Test-Path variable:global:__OrcaOsc133State')
|
||||
const languageModeGuardIndex = command.indexOf('LanguageMode -eq "FullLanguage"')
|
||||
const ompWrapperIndex = command.indexOf('function Global:omp')
|
||||
const ompExtensionIndex = command.indexOf('--extension $env:ORCA_OMP_STATUS_EXTENSION')
|
||||
const codexRestoreIndex = command.indexOf('$env:CODEX_HOME = $env:ORCA_CODEX_HOME')
|
||||
const promptIndex = command.indexOf('function Global:prompt')
|
||||
const cwdRestoreIndex = command.indexOf(
|
||||
expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'")
|
||||
)
|
||||
|
||||
expect(command).not.toContain('$PROFILE')
|
||||
expect(command).not.toContain('ORCA_PI_CODING_AGENT_DIR')
|
||||
expect(command).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
|
||||
expect(command).not.toContain('$env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_SOURCE_AGENT_DIR')
|
||||
for (const restoreIndex of [opencodeRestoreIndex, mimocodeRestoreIndex, codexRestoreIndex]) {
|
||||
expect(restoreIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(restoreIndex).toBeLessThan(duplicateStateGuardIndex)
|
||||
expect(restoreIndex).toBeLessThan(languageModeGuardIndex)
|
||||
}
|
||||
expect(outputEncodingIndex).toBeGreaterThan(languageModeGuardIndex)
|
||||
expect(outputEncodingIndex).toBeGreaterThan(duplicateStateGuardIndex)
|
||||
expect(ompWrapperIndex).toBeGreaterThan(outputEncodingIndex)
|
||||
expect(ompExtensionIndex).toBeGreaterThan(ompWrapperIndex)
|
||||
expect(promptIndex).toBeGreaterThan(ompWrapperIndex)
|
||||
expect(cwdRestoreIndex).toBeGreaterThan(promptIndex)
|
||||
expect(command).toContain('Esc = [char]27')
|
||||
expect(command).toContain('Bel = [char]7')
|
||||
expect(command).toContain(')]133;D;$fakeExitCode$(')
|
||||
expect(command).toContain(')]133;C$(')
|
||||
expect(command).not.toContain('`e]133')
|
||||
})
|
||||
|
||||
it('normalizes MSYS drive cwd before spawning native PowerShell', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'/c/Users/alice/project',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice\\project')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
|
||||
expect(decodePowerShellCommand(result)).toContain(
|
||||
expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice\\project'")
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes the PowerShell cwd restore command literally', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
"C:\\Users\\alice\\client's app",
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
|
||||
expect(decodePowerShellCommand(result)).toContain(
|
||||
expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice\\client''s app'")
|
||||
)
|
||||
})
|
||||
|
||||
it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
"& 'codex' '--no-alt-screen'"
|
||||
)
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
|
||||
|
||||
const command = decodePowerShellCommand(result)
|
||||
expect(command).toContain('function Global:prompt')
|
||||
expect(command).toContain(expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'"))
|
||||
expect(command.trimEnd().endsWith("& 'codex' '--no-alt-screen'")).toBe(true)
|
||||
// Without the mark, the first prompt after the agent exits withholds 133;D (#18180).
|
||||
expect(command).toContain(
|
||||
`${POWERSHELL_STARTUP_COMMAND_C_MARK}; $Global:__OrcaOsc133State.HasSeenPrompt = $true }\n& 'codex' '--no-alt-screen'`
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves complex PowerShell startup command text through EncodedCommand', () => {
|
||||
const startupCommand =
|
||||
'& "C:\\Program Files\\Orca CLI\\orca.exe" "--label" "quoted value"; $env:ORCA_VALUE = "nested"'
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
startupCommand
|
||||
)
|
||||
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
|
||||
const command = decodePowerShellCommand(result)
|
||||
expect(command).toContain(`\n${startupCommand}`)
|
||||
expect(command.trimEnd().endsWith(startupCommand)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps large PowerShell startup commands on stdin delivery', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
`orca ${'x'.repeat(7000)}`
|
||||
)
|
||||
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)])
|
||||
const command = decodePowerShellCommand(result)
|
||||
expect(command).toContain(expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'"))
|
||||
// Stdin-typed commands get readline's 133;C; an early latch would emit a false 133;D.
|
||||
expect(command).not.toContain(POWERSHELL_STARTUP_COMMAND_C_MARK)
|
||||
})
|
||||
|
||||
it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('pwsh.exe', 'C:\\', 'C:\\Users\\alice')
|
||||
expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)])
|
||||
expect(decodePowerShellCommand(result)).toContain(expectedPowerShellRestoreCwdCommand("'C:\\'"))
|
||||
})
|
||||
|
||||
it('does not change PowerShell args when the cmd/Git Bash preflight is provided', () => {
|
||||
const baseline = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
const withWindowsPreflight = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
expect(withWindowsPreflight).toEqual(baseline)
|
||||
})
|
||||
|
||||
it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'/c',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
// Git Bash inherits OEM codepage; UTF-8 must precede exec of the interactive shell.
|
||||
expect(result.shellArgs[0]).toBe('-c')
|
||||
const bashCommand = result.shellArgs[1]
|
||||
expect(bashCommand).toContain('chcp.com 65001')
|
||||
expect(bashCommand).toContain('--rcfile')
|
||||
expect(bashCommand).toMatch(/exec "\$BASH" .* -i$/)
|
||||
expect(bashCommand.indexOf('chcp.com')).toBeLessThan(bashCommand.indexOf('exec'))
|
||||
// Missing chcp.com must not prevent exec; semicolon preserves startup.
|
||||
expect(bashCommand).not.toContain('&&')
|
||||
expect(result.effectiveCwd).toBe('C:\\')
|
||||
expect(result.validationCwd).toBe('C:\\')
|
||||
|
||||
const bashRcfile = readFileSync(getGitBashRcfilePath(bashCommand), 'utf8')
|
||||
expect(bashRcfile).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex')
|
||||
expect(bashRcfile).not.toContain(CODEX_LAUNCH_PREFLIGHT)
|
||||
})
|
||||
|
||||
it('keeps Git Bash startup commands on stdin delivery with the preflight wrapper', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex --no-alt-screen',
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
expect(result.shellArgs[1]).toContain('chcp.com 65001')
|
||||
expect(result.shellArgs[1]).toContain('--rcfile')
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('gives a plain Git Bash tab the same rcfile as one with a startup command', () => {
|
||||
const plain = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
const launched = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
"codex 'fix the bug'"
|
||||
)
|
||||
|
||||
expect(plain.shellArgs).toEqual(launched.shellArgs)
|
||||
// The rcfile owns account-following Claude and Codex no-daemon wrappers.
|
||||
const rcfile = readFileSync(getGitBashRcfilePath(plain.shellArgs[1]), 'utf8')
|
||||
expect(rcfile).toContain('function claude {')
|
||||
expect(rcfile).toContain('set -- --no-daemon "$@"')
|
||||
expect(rcfile).toContain('source "$HOME/.bash_profile"')
|
||||
expect(launched.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('quotes a spaced preflight path through each shell environment', () => {
|
||||
const cmd = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
const gitBash = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
expect(cmd.shellArgs[1]).toContain(
|
||||
'call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%'
|
||||
)
|
||||
expect(cmd.shellArgs[1]).not.toContain('"')
|
||||
expect(cmd.shellArgs[1]).not.toContain(CODEX_LAUNCH_PREFLIGHT)
|
||||
const rcfilePath = getGitBashRcfilePath(gitBash.shellArgs[1])
|
||||
expect(readFileSync(rcfilePath, 'utf8')).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}"')
|
||||
})
|
||||
|
||||
it('does not apply Git Bash launch args to unrelated bash.exe paths', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'C:\\msys64\\usr\\bin\\bash.exe',
|
||||
'C:\\Users\\alice\\code',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual([])
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\code')
|
||||
})
|
||||
|
||||
it('translates Windows cwd to /mnt/<drive>/... for wsl.exe', () => {
|
||||
const baseline = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'C:\\Users\\alice\\code',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex'
|
||||
)
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'C:\\Users\\alice\\code',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex',
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result).toEqual(baseline)
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code'))
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
// Launch wsl.exe from Windows home; shell argv performs the guest Linux cd.
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\code')
|
||||
})
|
||||
|
||||
it('materializes shell-ready wrappers before building WSL shell args', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'C:\\Users\\alice\\code',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code'))
|
||||
expect(existsSync(join(getShellReadyWrapperRoot(), 'bash', 'rcfile'))).toBe(true)
|
||||
expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'))).toBe(true)
|
||||
|
||||
// Typed OMP uses status integration; typed Prime must reach user argv unchanged.
|
||||
const bashRcfile = readFileSync(join(getShellReadyWrapperRoot(), 'bash', 'rcfile'), 'utf8')
|
||||
const zshEnv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
|
||||
for (const wrapperFile of [bashRcfile, zshEnv]) {
|
||||
expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"')
|
||||
expect(wrapperFile).toContain('function omp { __orca_omp "$@"; }')
|
||||
expect(wrapperFile).not.toContain('prime-agent()')
|
||||
expect(wrapperFile).not.toContain('__orca_prime_agent')
|
||||
expect(wrapperFile).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION')
|
||||
expect(wrapperFile).not.toContain('command prime-agent --extension')
|
||||
}
|
||||
})
|
||||
|
||||
it('translates MSYS drive cwd to /mnt/<drive>/... for wsl.exe', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'/c/Users/alice/project',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex'
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project'))
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
|
||||
})
|
||||
|
||||
it.each(['/a', '/c'])('keeps a selected WSL runtime single-letter cwd exact (%s)', (cwd) => {
|
||||
const result = resolveWindowsShellLaunchArgs('wsl.exe', cwd, 'C:\\Users\\alice', {
|
||||
distro: 'Ubuntu',
|
||||
treatPosixCwdAsWsl: true
|
||||
})
|
||||
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs(cwd, 'Ubuntu'))
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe(`\\\\wsl.localhost\\Ubuntu\\${cwd.slice(1)}`)
|
||||
})
|
||||
|
||||
it('escapes single quotes when translating a WSL cwd', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice')
|
||||
// Single quotes in paths must remain within the generated sh argument.
|
||||
expect(result.shellArgs[3]).toContain("cd '/mnt/c/weird'\\''path'")
|
||||
expect(result.shellArgs[3]).toContain('exec "$_orca_wsl_shell" -l')
|
||||
})
|
||||
|
||||
it('falls back to /mnt/c when cwd is not a drive-letter path', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('wsl.exe', '\\\\server\\share', 'C:\\Users\\alice')
|
||||
expect(result.shellArgs[3]).toContain('cd \'/mnt/c\' && export PATH="$HOME/.local/bin:$PATH"')
|
||||
})
|
||||
|
||||
it('keeps WSL UNC worktree cwd inside the matching distro', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
|
||||
try {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo', 'Ubuntu'))
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo')
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps POSIX cwd inside the worktree distro when WSL context is provided', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'/home/alice/repo/subdir',
|
||||
'C:\\Users\\alice',
|
||||
{ distro: 'Ubuntu', treatPosixCwdAsWsl: true }
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo/subdir', 'Ubuntu'))
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\subdir')
|
||||
})
|
||||
|
||||
it('falls back to empty args + same cwd for unknown shells', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'C:\\tools\\fish.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
expect(result.shellArgs).toEqual([])
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice')
|
||||
})
|
||||
|
||||
it('is case-insensitive on the shell basename', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('PowerShell.EXE', 'C:\\', 'C:\\')
|
||||
expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)])
|
||||
})
|
||||
})
|
||||
|
||||
// #7236: UTF-16 base64 delivers setup-runner quotes intact instead of risking partial typed-stdin parsing.
|
||||
describe('issue #7236: PowerShell setup-runner command delivery', () => {
|
||||
// git rev-parse hands back a forward-slash Windows-absolute path for the runner.
|
||||
const runnerPath = 'C:/Users/alice/repo/.git/orca/setup-runner.cmd'
|
||||
|
||||
it('wraps the setup runner in balanced double quotes', () => {
|
||||
const { command } = resolveSetupRunnerCommand(runnerPath, 'windows')
|
||||
expect(command).toBe(`cmd.exe /c "${runnerPath}"`)
|
||||
expect((command.match(/"/g) ?? []).length % 2).toBe(0)
|
||||
})
|
||||
|
||||
it('delivers the setup-runner command through -EncodedCommand, never raw stdin', () => {
|
||||
const { command } = resolveSetupRunnerCommand(runnerPath, 'windows')
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'C:\\Users\\alice\\repo',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
command
|
||||
)
|
||||
|
||||
// The argv-delivery flag prevents a second unencoded stdin write (#7236).
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
|
||||
expect(result.shellArgs.slice(0, 3)).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand'])
|
||||
|
||||
const decoded = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le')
|
||||
expect(decoded).toContain(`\n${command}`)
|
||||
expect(decoded.trimEnd().endsWith(command)).toBe(true)
|
||||
expect((decoded.slice(decoded.lastIndexOf(command)).match(/"/g) ?? []).length % 2).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -17,12 +17,6 @@ import {
|
||||
|
||||
let tempDir: string
|
||||
|
||||
/** Let the UNC lane-acquire microtask run. */
|
||||
const flushLaneAcquire = async (): Promise<void> => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
wslUncDirectoryExistsAsyncMock.mockReset()
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
|
||||
@@ -35,36 +29,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('validateWorkingDirectoryAsync', () => {
|
||||
it('accepts an existing directory', async () => {
|
||||
await expect(validateWorkingDirectoryAsync(tempDir)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a missing directory with the actionable unmounted-volume message', async () => {
|
||||
await expect(validateWorkingDirectoryAsync(path.join(tempDir, 'gone'))).rejects.toThrow(
|
||||
/does not exist.*unmounted volume/s
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a path that exists but is a file', async () => {
|
||||
const filePath = path.join(tempDir, 'not-a-dir.txt')
|
||||
await writeFile(filePath, 'x')
|
||||
|
||||
await expect(validateWorkingDirectoryAsync(filePath)).rejects.toThrow('is not a directory')
|
||||
})
|
||||
|
||||
it('never blocks the event loop while the filesystem answers', async () => {
|
||||
// A dead UNC share must not freeze unrelated daemon RPCs (STA-4470).
|
||||
let ticked = false
|
||||
const pending = validateWorkingDirectoryAsync(tempDir)
|
||||
setImmediate(() => {
|
||||
ticked = true
|
||||
})
|
||||
|
||||
await pending
|
||||
|
||||
expect(ticked).toBe(true)
|
||||
})
|
||||
|
||||
describe('cancellation', () => {
|
||||
// Unsettled probes persist in the module-level dedupe map; use a distinct path per test.
|
||||
const deadShare = (name: string): string => `\\\\wsl.localhost\\Ubuntu\\dead-${name}`
|
||||
@@ -77,25 +41,6 @@ describe('validateWorkingDirectoryAsync', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('lets an aborted caller give up on a probe that never answers', async () => {
|
||||
// fs.stat cannot abort; only its caller stops waiting.
|
||||
wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise<boolean>(() => {}))
|
||||
const abort = new AbortController()
|
||||
|
||||
const pending = validateWorkingDirectoryAsync(deadShare('abort'), { signal: abort.signal })
|
||||
abort.abort()
|
||||
|
||||
await expect(pending).rejects.toThrow('was canceled')
|
||||
})
|
||||
|
||||
it('rejects immediately when the signal is already aborted', async () => {
|
||||
wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise<boolean>(() => {}))
|
||||
|
||||
await expect(
|
||||
validateWorkingDirectoryAsync(deadShare('pre-aborted'), { signal: AbortSignal.abort() })
|
||||
).rejects.toThrow('was canceled')
|
||||
})
|
||||
|
||||
it('leaves the shared probe intact for callers that are still waiting', async () => {
|
||||
let releaseProbe: () => void = () => {}
|
||||
wslUncDirectoryExistsAsyncMock.mockReturnValue(
|
||||
@@ -115,33 +60,6 @@ describe('validateWorkingDirectoryAsync', () => {
|
||||
await expect(staying).resolves.toBeUndefined()
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('never pins a second probe on a path whose probe is still hung', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise<boolean>(() => {}))
|
||||
const hung = deadShare('still-hung')
|
||||
|
||||
// Expiring an uninterruptible stat would let each retry pin another libuv thread.
|
||||
for (let retry = 0; retry < 4; retry += 1) {
|
||||
void validateWorkingDirectoryAsync(hung).catch(() => {})
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
}
|
||||
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-probes once the previous probe settles, so a recovered mount is seen', async () => {
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
|
||||
const recovering = deadShare('recovers')
|
||||
|
||||
await expect(validateWorkingDirectoryAsync(recovering)).rejects.toThrow(/does not exist/)
|
||||
await expect(validateWorkingDirectoryAsync(recovering)).resolves.toBeUndefined()
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UNC route concurrency', () => {
|
||||
@@ -153,28 +71,6 @@ describe('validateWorkingDirectoryAsync', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('caps concurrent probes against one unreachable distro', async () => {
|
||||
// Four dead paths could occupy the default libuv fs pool.
|
||||
let inFlight = 0
|
||||
let peak = 0
|
||||
wslUncDirectoryExistsAsyncMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>(() => {
|
||||
inFlight += 1
|
||||
peak = Math.max(peak, inFlight)
|
||||
})
|
||||
)
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\Ubuntu\\capped-${index}`).catch(
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
await flushLaneAcquire()
|
||||
|
||||
expect(peak).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps a healthy local path out of a stalled share queue', async () => {
|
||||
wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise<boolean>(() => {}))
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
@@ -185,89 +81,5 @@ describe('validateWorkingDirectoryAsync', () => {
|
||||
|
||||
await expect(validateWorkingDirectoryAsync(tempDir)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('gives separate servers separate lanes', async () => {
|
||||
let inFlight = 0
|
||||
wslUncDirectoryExistsAsyncMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>(() => {
|
||||
inFlight += 1
|
||||
})
|
||||
)
|
||||
|
||||
for (const distro of ['AlphaDistro', 'BetaDistro']) {
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\${distro}\\lane-${index}`).catch(
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
}
|
||||
await flushLaneAcquire()
|
||||
|
||||
expect(inFlight).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WSL UNC paths', () => {
|
||||
const wslPath = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('asks the distro asynchronously and accepts its yes', async () => {
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(true)
|
||||
|
||||
await expect(validateWorkingDirectoryAsync(wslPath)).resolves.toBeUndefined()
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(wslPath)
|
||||
})
|
||||
|
||||
it('shares one in-flight probe for the same working directory', async () => {
|
||||
let releaseProbe: () => void = () => {}
|
||||
wslUncDirectoryExistsAsyncMock.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
releaseProbe = () => resolve(true)
|
||||
})
|
||||
)
|
||||
|
||||
const first = validateWorkingDirectoryAsync(wslPath)
|
||||
const second = validateWorkingDirectoryAsync(wslPath)
|
||||
await flushLaneAcquire()
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce()
|
||||
|
||||
releaseProbe()
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
it('keeps byte-distinct working directories in separate probes', async () => {
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(true)
|
||||
const decomposedPath = `${wslPath}-e\u0301`
|
||||
const composedPath = `${wslPath}-\u00e9`
|
||||
|
||||
await Promise.all([
|
||||
validateWorkingDirectoryAsync(decomposedPath),
|
||||
validateWorkingDirectoryAsync(composedPath)
|
||||
])
|
||||
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it(`rejects on the distro no without falling back to a Win32 stat`, async () => {
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
|
||||
|
||||
await expect(validateWorkingDirectoryAsync(wslPath)).rejects.toThrow(/does not exist/)
|
||||
})
|
||||
|
||||
it('falls back to the filesystem when the distro probe is inconclusive', async () => {
|
||||
// An inconclusive distro probe must fall back to filesystem validation.
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
|
||||
|
||||
await expect(validateWorkingDirectoryAsync(wslPath)).rejects.toThrow(/does not exist/)
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
readPersistedWindowsPathSegments,
|
||||
readPersistedWindowsPathSegmentsAsync
|
||||
} from './windows-environment-path'
|
||||
import { resolvePathEnvKey } from './windows-path-segment-merge'
|
||||
import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader'
|
||||
|
||||
const registryGetKeyMock = vi.fn()
|
||||
@@ -41,164 +40,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('readPersistedWindowsPathSegments', () => {
|
||||
it('reads machine and user Path values from the Windows registry', () => {
|
||||
const execFileSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(
|
||||
[
|
||||
'',
|
||||
'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment',
|
||||
' Path REG_EXPAND_SZ %SystemRoot%\\System32;C:\\Tools',
|
||||
''
|
||||
].join('\r\n')
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
['', 'HKEY_CURRENT_USER\\Environment', ' Path REG_SZ C:\\Users\\me\\bin', ''].join(
|
||||
'\r\n'
|
||||
)
|
||||
)
|
||||
|
||||
const segments = readPersistedWindowsPathSegments({
|
||||
platform: 'win32',
|
||||
execFileSync,
|
||||
env: { SystemRoot: 'C:\\Windows' }
|
||||
})
|
||||
|
||||
expect(segments).toEqual(['C:\\Windows\\System32', 'C:\\Tools', 'C:\\Users\\me\\bin'])
|
||||
expect(execFileSync).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
execFileSync.mock.calls.every(([command]) => command === 'C:\\Windows\\System32\\reg.exe')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns an empty list outside Windows', () => {
|
||||
const execFileSync = vi.fn()
|
||||
|
||||
expect(readPersistedWindowsPathSegments({ platform: 'linux', execFileSync })).toEqual([])
|
||||
expect(execFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start asynchronous registry reads outside Windows', async () => {
|
||||
await expect(readPersistedWindowsPathSegmentsAsync({ platform: 'linux' })).resolves.toEqual([])
|
||||
expect(defaultExecFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caches production registry reads briefly', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
registryGetKeyMock
|
||||
.mockReturnValueOnce(registryPath('C:\\Machine'))
|
||||
.mockReturnValueOnce(registryPath('C:\\User'))
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
|
||||
expect(registryGetKeyMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('force-refreshes the production registry cache', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
registryGetKeyMock
|
||||
.mockReturnValueOnce(registryPath('C:\\Machine'))
|
||||
.mockReturnValueOnce(registryPath('C:\\User'))
|
||||
.mockReturnValueOnce(registryPath('C:\\Machine'))
|
||||
.mockReturnValueOnce(registryPath('C:\\User;C:\\Program Files\\GitHub CLI'))
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
|
||||
expect(registryGetKeyMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([
|
||||
'C:\\Machine',
|
||||
'C:\\User',
|
||||
'C:\\Program Files\\GitHub CLI'
|
||||
])
|
||||
expect(registryGetKeyMock).toHaveBeenCalledTimes(4)
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidates the cache after Windows reports an environment change', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
registryGetKeyMock
|
||||
.mockReturnValueOnce(registryPath('C:\\OldMachine'))
|
||||
.mockReturnValueOnce(registryPath('C:\\OldUser'))
|
||||
.mockReturnValueOnce(registryPath('C:\\NewMachine'))
|
||||
.mockReturnValueOnce(registryPath('C:\\NewUser'))
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser'])
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser'])
|
||||
|
||||
invalidatePersistedWindowsPathCache()
|
||||
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\NewMachine', 'C:\\NewUser'])
|
||||
expect(registryGetKeyMock).toHaveBeenCalledTimes(4)
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('combines a fresh hive with the other hive last known good after invalidation', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
registryGetKeyMock
|
||||
.mockReturnValueOnce(registryPath('C:\\OldMachine'))
|
||||
.mockReturnValueOnce(registryPath('C:\\OldUser'))
|
||||
.mockReturnValueOnce(registryPath('C:\\NewMachine'))
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('user registry unavailable')
|
||||
})
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser'])
|
||||
invalidatePersistedWindowsPathCache()
|
||||
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\NewMachine', 'C:\\OldUser'])
|
||||
expect(registryGetKeyMock).toHaveBeenCalledTimes(4)
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('combines a fresh user hive with the machine hive last known good', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
registryGetKeyMock
|
||||
.mockReturnValueOnce(registryPath('C:\\OldMachine'))
|
||||
.mockReturnValueOnce(registryPath('C:\\OldUser'))
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('machine registry unavailable')
|
||||
})
|
||||
.mockReturnValueOnce(registryPath('C:\\NewUser'))
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser'])
|
||||
invalidatePersistedWindowsPathCache()
|
||||
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\NewUser'])
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('withholds a partial first registry snapshot instead of changing PATH precedence', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
@@ -223,115 +64,6 @@ describe('readPersistedWindowsPathSegments', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('withholds the reverse partial first snapshot', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
registryGetKeyMock
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('machine registry unavailable')
|
||||
})
|
||||
.mockReturnValueOnce(registryPath('C:\\User'))
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
expect(readPersistedWindowsPathSegments()).toEqual([])
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not duplicate an unresolved asynchronous refresh for a synchronous caller', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
registryGetKeyMock
|
||||
.mockReturnValueOnce(registryPath('C:\\Machine'))
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('user registry unavailable')
|
||||
})
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
const refresh = readPersistedWindowsPathSegmentsAsync()
|
||||
|
||||
expect(readPersistedWindowsPathSegments()).toEqual([])
|
||||
await expect(refresh).resolves.toEqual([])
|
||||
expect(registryGetKeyMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let injected test reads populate production fallback state', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const execFileSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(' Path REG_SZ C:\\InjectedMachine\r\n')
|
||||
.mockReturnValueOnce(' Path REG_SZ C:\\InjectedUser\r\n')
|
||||
registryGetKeyMock.mockImplementation(() => {
|
||||
throw new Error('registry unavailable')
|
||||
})
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
expect(readPersistedWindowsPathSegments({ platform: 'win32', execFileSync })).toEqual([
|
||||
'C:\\InjectedMachine',
|
||||
'C:\\InjectedUser'
|
||||
])
|
||||
expect(readPersistedWindowsPathSegments()).toEqual([])
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the last good segments when a forced read hits a blocked registry', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
registryGetKeyMock
|
||||
.mockReturnValueOnce(registryPath('C:\\Machine'))
|
||||
.mockReturnValueOnce(registryPath('C:\\User'))
|
||||
.mockImplementation(() => {
|
||||
throw new Error('ERROR: Access is denied.')
|
||||
})
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
|
||||
expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([
|
||||
'C:\\Machine',
|
||||
'C:\\User'
|
||||
])
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('still clears the cache when the registry reports an empty Path', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
registryGetKeyMock
|
||||
.mockReturnValueOnce(registryPath('C:\\Machine'))
|
||||
.mockReturnValueOnce(registryPath('C:\\User'))
|
||||
.mockReturnValue(registryPath(''))
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
|
||||
|
||||
// An empty persisted PATH is successful, not a reason to serve stale cache.
|
||||
expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([])
|
||||
expect(readPersistedWindowsPathSegments()).toEqual([])
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not resurrect a successfully emptied hive when the other hive fails', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
@@ -359,66 +91,6 @@ describe('readPersistedWindowsPathSegments', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
registryGetKeyMock
|
||||
.mockReturnValueOnce(registryPath('C:\\Machine'))
|
||||
.mockReturnValueOnce(registryPath('C:\\User'))
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
const firstEnv = { Path: 'C:\\First' }
|
||||
const secondEnv = { Path: 'C:\\Second' }
|
||||
const first = mergePersistedWindowsPathAsync(firstEnv, { forceRefresh: true })
|
||||
const second = mergePersistedWindowsPathAsync(secondEnv, { forceRefresh: true })
|
||||
|
||||
await Promise.all([first, second])
|
||||
expect(firstEnv.Path).toBe('C:\\First;C:\\Machine;C:\\User')
|
||||
expect(secondEnv.Path).toBe('C:\\Second;C:\\Machine;C:\\User')
|
||||
expect(registryGetKeyMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the last good cache when native registry reads fail', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
registryGetKeyMock
|
||||
.mockReturnValueOnce(registryPath('C:\\Machine'))
|
||||
.mockReturnValueOnce(registryPath('C:\\User'))
|
||||
.mockImplementation(() => {
|
||||
throw new Error('registry unavailable')
|
||||
})
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
|
||||
try {
|
||||
await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([
|
||||
'C:\\Machine',
|
||||
'C:\\User'
|
||||
])
|
||||
await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([
|
||||
'C:\\Machine',
|
||||
'C:\\User'
|
||||
])
|
||||
expect(registryGetKeyMock).toHaveBeenCalledTimes(2)
|
||||
await expect(readPersistedWindowsPathSegmentsAsync({ forceRefresh: true })).resolves.toEqual([
|
||||
'C:\\Machine',
|
||||
'C:\\User'
|
||||
])
|
||||
await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([
|
||||
'C:\\Machine',
|
||||
'C:\\User'
|
||||
])
|
||||
expect(registryGetKeyMock).toHaveBeenCalledTimes(4)
|
||||
} finally {
|
||||
__resetPersistedWindowsPathCacheForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let an older async read replace a newer synchronous refresh', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
@@ -484,188 +156,3 @@ describe('readPersistedWindowsPathSegments', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergePersistedWindowsPath', () => {
|
||||
it('expands variables already present in the inherited PATH', () => {
|
||||
const execFileSync = vi.fn().mockReturnValue(' Path REG_SZ \r\n')
|
||||
const env = {
|
||||
ORCA_PATH_ROOT: 'C:\\Users\\orca\\AppData\\Local',
|
||||
Path: '%orca_path_root%\\agy\\bin;C:\\Windows'
|
||||
}
|
||||
|
||||
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync, env })
|
||||
|
||||
expect(env.Path).toBe('C:\\Users\\orca\\AppData\\Local\\agy\\bin;C:\\Windows')
|
||||
})
|
||||
|
||||
it('adopts persisted machine and user PATH ordering', () => {
|
||||
const execFileSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(
|
||||
[
|
||||
'',
|
||||
'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment',
|
||||
' Path REG_EXPAND_SZ C:\\Windows\\System32;C:\\Existing',
|
||||
''
|
||||
].join('\r\n')
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
[
|
||||
'',
|
||||
'HKEY_CURRENT_USER\\Environment',
|
||||
' Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Local\\Orca\\bin;C:\\Existing',
|
||||
''
|
||||
].join('\r\n')
|
||||
)
|
||||
const env = { Path: 'C:\\Existing' }
|
||||
|
||||
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
|
||||
|
||||
expect(env.Path).toBe(
|
||||
'C:\\Windows\\System32;C:\\Existing;C:\\Users\\me\\AppData\\Local\\Orca\\bin'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses PATH when that is the existing path key', () => {
|
||||
const execFileSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n')
|
||||
.mockReturnValueOnce(' Path REG_SZ C:\\User\r\n')
|
||||
const env = { PATH: 'C:\\Current' }
|
||||
|
||||
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
|
||||
|
||||
expect(env).toEqual({ PATH: 'C:\\Current;C:\\Machine;C:\\User' })
|
||||
})
|
||||
|
||||
it('keeps the inherited process PATH when the target env has no path key', () => {
|
||||
const execFileSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n')
|
||||
.mockReturnValueOnce(' Path REG_SZ C:\\User\r\n')
|
||||
const env: Record<string, string> = {}
|
||||
|
||||
mergePersistedWindowsPath(env, {
|
||||
platform: 'win32',
|
||||
execFileSync,
|
||||
env: { Path: 'C:\\Inherited' }
|
||||
})
|
||||
|
||||
expect(env).toEqual({ Path: 'C:\\Inherited;C:\\Machine;C:\\User' })
|
||||
})
|
||||
|
||||
it('reads the live host spelling when a path-less env inherits duplicate keys', () => {
|
||||
const execFileSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n')
|
||||
.mockReturnValueOnce(' Path REG_SZ C:\\User\r\n')
|
||||
const env: Record<string, string> = {}
|
||||
|
||||
mergePersistedWindowsPath(env, {
|
||||
platform: 'win32',
|
||||
execFileSync,
|
||||
env: { ROOT: 'C:\\Root', Path: '%ROOT%\\Live', PATH: 'C:\\Shadowed' }
|
||||
})
|
||||
|
||||
expect(env).toEqual({ Path: 'C:\\Root\\Live;C:\\Machine;C:\\User' })
|
||||
})
|
||||
|
||||
it('stops an inherited WindowsApps alias from shadowing a newly installed Python', () => {
|
||||
const python = 'C:\\Users\\me\\AppData\\Local\\Programs\\Python\\Python314\\'
|
||||
const scripts = `${python}Scripts\\`
|
||||
const windowsApps = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps'
|
||||
const execFileSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(' Path REG_EXPAND_SZ C:\\Windows\\system32;C:\\Windows\r\n')
|
||||
.mockReturnValueOnce(` Path REG_EXPAND_SZ ${python};${scripts};${windowsApps}\r\n`)
|
||||
const env = { Path: `C:\\Windows\\system32;C:\\Windows;${windowsApps}` }
|
||||
|
||||
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
|
||||
|
||||
const segments = env.Path.split(';')
|
||||
expect(segments.indexOf(python)).toBeLessThan(segments.indexOf(windowsApps))
|
||||
expect(env.Path).toBe(`C:\\Windows\\system32;C:\\Windows;${python};${scripts};${windowsApps}`)
|
||||
})
|
||||
|
||||
it('keeps injected entries ahead of the persisted PATH', () => {
|
||||
const injected = 'C:\\Users\\me\\AppData\\Local\\Orca\\resources\\bin'
|
||||
const python = 'C:\\Users\\me\\AppData\\Local\\Programs\\Python\\Python314\\'
|
||||
const windowsApps = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps'
|
||||
const execFileSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(' Path REG_EXPAND_SZ C:\\Windows\\system32\r\n')
|
||||
.mockReturnValueOnce(` Path REG_EXPAND_SZ ${python};${windowsApps}\r\n`)
|
||||
const env = { Path: `${injected};C:\\Windows\\system32;${windowsApps}` }
|
||||
|
||||
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
|
||||
|
||||
expect(env.Path).toBe(`${injected};C:\\Windows\\system32;${python};${windowsApps}`)
|
||||
})
|
||||
|
||||
it('leaves the inherited PATH untouched when both registry reads are blocked', () => {
|
||||
const execFileSync = vi.fn().mockImplementation(() => {
|
||||
throw new Error('ERROR: Access is denied.')
|
||||
})
|
||||
const env = { Path: 'C:\\Windows\\system32;C:\\Existing' }
|
||||
|
||||
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
|
||||
|
||||
expect(env.Path).toBe('C:\\Windows\\system32;C:\\Existing')
|
||||
})
|
||||
|
||||
it('deduplicates segments that differ only by trailing separators', () => {
|
||||
const execFileSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(' Path REG_SZ C:\\Tools\r\n')
|
||||
.mockReturnValueOnce(' Path REG_SZ \r\n')
|
||||
const env = { Path: 'C:\\Tools\\' }
|
||||
|
||||
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
|
||||
|
||||
expect(env.Path.split(';')).toEqual(['C:\\Tools'])
|
||||
})
|
||||
|
||||
it('canonicalizes drive-root slash style and repetition for comparison', () => {
|
||||
const execFileSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(' Path REG_SZ C:\\\\\r\n')
|
||||
.mockReturnValueOnce(' Path REG_SZ \r\n')
|
||||
const env = { Path: 'C:/' }
|
||||
|
||||
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
|
||||
|
||||
expect(env.Path.split(';')).toEqual(['C:\\\\'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePathEnvKey', () => {
|
||||
it('uses whichever Windows spelling is present on the env', () => {
|
||||
expect(resolvePathEnvKey({ Path: 'C:\\Windows' }, 'win32')).toBe('Path')
|
||||
expect(resolvePathEnvKey({ PATH: 'C:\\Windows' }, 'win32')).toBe('PATH')
|
||||
expect(resolvePathEnvKey({ path: 'C:\\Windows' }, 'win32')).toBe('path')
|
||||
expect(resolvePathEnvKey({ PaTh: 'C:\\Windows' }, 'win32')).toBe('PaTh')
|
||||
})
|
||||
|
||||
// Win32 resolves the first case-insensitive block entry; casing alone cannot select the live key.
|
||||
it('resolves a dual-cased Windows env by block order, not casing', () => {
|
||||
expect(resolvePathEnvKey({ PATH: 'C:\\Live', Path: 'C:\\Shadowed' }, 'win32')).toBe('PATH')
|
||||
expect(resolvePathEnvKey({ Path: 'C:\\Live', PATH: 'C:\\Shadowed' }, 'win32')).toBe('Path')
|
||||
})
|
||||
|
||||
it('skips a path key explicitly set to undefined', () => {
|
||||
expect(resolvePathEnvKey({ PATH: undefined, Path: 'C:\\Windows' }, 'win32')).toBe('Path')
|
||||
})
|
||||
|
||||
it('falls back to the host block spelling for a Windows env with no path key', () => {
|
||||
// Match the daemon merge key to avoid creating a shadow PATH entry.
|
||||
expect(resolvePathEnvKey({}, 'win32', { PATH: 'C:\\Windows' })).toBe('PATH')
|
||||
expect(resolvePathEnvKey({}, 'win32', { Path: 'C:\\Windows' })).toBe('Path')
|
||||
expect(resolvePathEnvKey({}, 'win32', { Path: 'C:\\Live', PATH: 'C:\\Shadowed' })).toBe('Path')
|
||||
expect(resolvePathEnvKey({}, 'win32', {})).toBe('Path')
|
||||
})
|
||||
|
||||
it('always resolves `PATH` off Windows so a POSIX `Path` variable is untouched', () => {
|
||||
expect(resolvePathEnvKey({ Path: '/decoy' }, 'linux')).toBe('PATH')
|
||||
expect(resolvePathEnvKey({ Path: '/decoy', PATH: '/usr/bin' }, 'darwin')).toBe('PATH')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,9 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
deleteFishHistoryFile,
|
||||
fishHistorySessionName,
|
||||
isSafeFishHistorySession,
|
||||
relayFishHistorySessionName,
|
||||
resolveFishHistoryDir,
|
||||
sweepOrphanedFishHistoryFiles
|
||||
} from './fish-history-session'
|
||||
|
||||
@@ -15,47 +13,6 @@ const HASH_A = 'a1b2c3d4e5f60718'
|
||||
const HASH_B = '00112233445566ff'
|
||||
const SESSION_A = fishHistorySessionName(HASH_A)
|
||||
|
||||
describe('fish history session naming', () => {
|
||||
it('mints a session name that is a valid fish variable value', () => {
|
||||
// Invalid fish variable names silently select shared history.
|
||||
expect(SESSION_A).toBe('orca_a1b2c3d4e5f60718')
|
||||
expect(SESSION_A).toMatch(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['orca_deadbeef', true],
|
||||
['orca_', false],
|
||||
['fish_history', false],
|
||||
['orca_../../etc/passwd', false],
|
||||
['orca_DEADBEEF', false],
|
||||
['', false],
|
||||
[undefined, false],
|
||||
[42, false]
|
||||
])('accepts %s as a safe session name: %s', (value, expected) => {
|
||||
expect(isSafeFishHistorySession(value)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fish history directory resolution', () => {
|
||||
it('follows XDG_DATA_HOME when it is absolute', () => {
|
||||
expect(resolveFishHistoryDir({ XDG_DATA_HOME: '/data', HOME: '/home/me' })).toBe(
|
||||
join('/data', 'fish')
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a relative XDG_DATA_HOME the way fish does', () => {
|
||||
expect(resolveFishHistoryDir({ XDG_DATA_HOME: 'relative', HOME: '/home/me' })).toBe(
|
||||
join('/home/me', '.local', 'share', 'fish')
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to HOME when XDG_DATA_HOME is unset', () => {
|
||||
expect(resolveFishHistoryDir({ HOME: '/home/me' })).toBe(
|
||||
join('/home/me', '.local', 'share', 'fish')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fish history deletion', () => {
|
||||
let root: string
|
||||
let fishDir: string
|
||||
@@ -72,30 +29,6 @@ describe('fish history deletion', () => {
|
||||
|
||||
const historyFile = (session: string): string => join(fishDir, `${session}_history`)
|
||||
|
||||
it('deletes the session file rather than truncating it', () => {
|
||||
writeFileSync(historyFile(SESSION_A), '- cmd: ls\n')
|
||||
|
||||
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(true)
|
||||
expect(existsSync(historyFile(SESSION_A))).toBe(false)
|
||||
})
|
||||
|
||||
it('tries every candidate directory, since the PTY may not share this env', () => {
|
||||
const other = join(root, 'other-xdg', 'fish')
|
||||
mkdirSync(other, { recursive: true })
|
||||
writeFileSync(join(other, `${SESSION_A}_history`), '- cmd: ls\n')
|
||||
|
||||
expect(deleteFishHistoryFile(SESSION_A, [fishDir, other])).toBe(true)
|
||||
expect(existsSync(join(other, `${SESSION_A}_history`))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a session name it did not mint', () => {
|
||||
const foreign = join(fishDir, 'fish_history')
|
||||
writeFileSync(foreign, 'the user’s real history\n')
|
||||
|
||||
expect(deleteFishHistoryFile('fish_history', [fishDir])).toBe(false)
|
||||
expect(existsSync(foreign)).toBe(true)
|
||||
})
|
||||
|
||||
// Windows symlinks require Developer Mode or elevation.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'refuses to follow a symlink out of the fish data dir',
|
||||
@@ -108,10 +41,6 @@ describe('fish history deletion', () => {
|
||||
expect(existsSync(outside)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('reports false when there is nothing to delete', () => {
|
||||
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orphaned fish history sweep', () => {
|
||||
@@ -136,59 +65,6 @@ describe('orphaned fish history sweep', () => {
|
||||
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`))).toBe(true)
|
||||
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(false)
|
||||
})
|
||||
|
||||
it("never touches the user's own history files", () => {
|
||||
const untouched = ['fish_history', 'work_history', 'orca_history', 'orca_nothex_history']
|
||||
for (const name of untouched) {
|
||||
writeFileSync(join(fishDir, name), 'mine\n')
|
||||
}
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(0)
|
||||
for (const name of untouched) {
|
||||
expect(existsSync(join(fishDir, name))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to sweep on an empty live set, which cannot be told from a cold store', () => {
|
||||
writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set(), [fishDir])).toBe(0)
|
||||
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(true)
|
||||
})
|
||||
|
||||
it('spares a file younger than the age guard, since the live set is a snapshot', () => {
|
||||
// A workspace created after the live-set snapshot must keep its actively written history.
|
||||
const fresh = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`)
|
||||
writeFileSync(fresh, 'just created\n')
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000)).toBe(0)
|
||||
expect(existsSync(fresh)).toBe(true)
|
||||
})
|
||||
|
||||
it('sweeps a file older than the age guard', () => {
|
||||
const stale = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`)
|
||||
writeFileSync(stale, 'long dead\n')
|
||||
const wellPast = Date.now() + 60 * 60 * 1000
|
||||
|
||||
expect(
|
||||
sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000, wellPast)
|
||||
).toBe(1)
|
||||
expect(existsSync(stale)).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a directory that does not exist', () => {
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [join(root, 'absent')])).toBe(0)
|
||||
})
|
||||
|
||||
it('sweeps every candidate directory once', () => {
|
||||
const other = join(root, 'other', 'fish')
|
||||
mkdirSync(other, { recursive: true })
|
||||
for (const dir of [fishDir, other]) {
|
||||
writeFileSync(join(dir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
|
||||
}
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir, other, fishDir])).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Desktop live sets cannot account for remote clients’ history; prefixes keep their sweeps separate.
|
||||
@@ -203,14 +79,6 @@ describe('relay fish history naming', () => {
|
||||
rmSync(relayRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('is namespaced apart from desktop session names', () => {
|
||||
expect(relayFishHistorySessionName(HASH_A)).not.toBe(fishHistorySessionName(HASH_A))
|
||||
})
|
||||
|
||||
it('is still a safe session name, so the relay can delete it by name', () => {
|
||||
expect(isSafeFishHistorySession(relayFishHistorySessionName(HASH_A))).toBe(true)
|
||||
})
|
||||
|
||||
it('survives a desktop sweep that knows none of the relay worktree ids', () => {
|
||||
const dir = relayRoot
|
||||
const relayFile = join(dir, `${relayFishHistorySessionName(HASH_A)}_history`)
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installFakeAppEnvironment } from '../../../../config/scripts/test-runtime/environment/vitest-host-ports-setup'
|
||||
|
||||
@@ -22,7 +22,6 @@ vi.mock('../../filesystem/host-tree-removal', () => ({
|
||||
}))
|
||||
|
||||
import * as eventLoopYield from '../../../shared/event-loop-yield'
|
||||
import { readHistoryMeta } from './terminal-history'
|
||||
import {
|
||||
cancelPendingHistoryTreeRemovalRetries,
|
||||
flushPendingWorktreeHistoryDeletions
|
||||
@@ -38,50 +37,6 @@ let userDataDir: string
|
||||
let historyRoot: string
|
||||
let originalXdgDataHome: string | undefined
|
||||
|
||||
// Pre-change pruning oracle; report decisions without deleting history.
|
||||
function referenceSyncPruneDecisions(root: string, liveWorktreeIds: Set<string>): string[] {
|
||||
const decisions: string[] = []
|
||||
if (!existsSync(root)) {
|
||||
return decisions
|
||||
}
|
||||
const now = Date.now()
|
||||
for (const entry of readdirSync(root)) {
|
||||
if (entry === PENDING_DELETE_DIR_NAME) {
|
||||
continue
|
||||
}
|
||||
const entryPath = join(root, entry)
|
||||
try {
|
||||
const stats = statSync(entryPath)
|
||||
if (!stats.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
for (const file of readdirSync(entryPath)) {
|
||||
statSync(join(entryPath, file))
|
||||
}
|
||||
} catch {
|
||||
// Match the original walk's tolerance for failed size estimates.
|
||||
}
|
||||
if (!existsSync(join(entryPath, 'meta.json'))) {
|
||||
continue
|
||||
}
|
||||
const meta = readHistoryMeta(entryPath)
|
||||
if (!meta?.worktreeId) {
|
||||
continue
|
||||
}
|
||||
if (!liveWorktreeIds.has(meta.worktreeId)) {
|
||||
if (meta.createdAt && now - new Date(meta.createdAt).getTime() < GC_MIN_AGE_MS) {
|
||||
continue
|
||||
}
|
||||
decisions.push(entry)
|
||||
}
|
||||
} catch {
|
||||
// Match the original walk's tolerance for unreadable entries.
|
||||
}
|
||||
}
|
||||
return decisions
|
||||
}
|
||||
|
||||
function seedDir(name: string, files: Record<string, string>): string {
|
||||
const dir = join(historyRoot, name)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
@@ -164,13 +119,6 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// Strip the tombstone timestamp/random suffix to compare original entry names.
|
||||
function tombstonedNames(): Set<string> {
|
||||
return new Set(
|
||||
removeHostTreeMock.mock.calls.map(([dir]) => basename(dir).split('.').slice(0, -2).join('.'))
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
cancelHistoryGc()
|
||||
vi.useRealTimers()
|
||||
@@ -185,23 +133,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('history GC prune decisions', () => {
|
||||
it('prunes exactly the set the synchronous walk chose', async () => {
|
||||
seedDecisionMatrix()
|
||||
seedBulk(200, 7)
|
||||
const live = new Set([LIVE_WORKTREE_ID])
|
||||
|
||||
const before = survivingDirs()
|
||||
const expected = new Set(referenceSyncPruneDecisions(historyRoot, live))
|
||||
|
||||
await runHistoryGc(live)
|
||||
|
||||
const after = survivingDirs()
|
||||
const actual = new Set([...before].filter((entry) => !after.has(entry)))
|
||||
|
||||
expect(expected.size).toBeGreaterThan(0)
|
||||
expect([...actual].sort()).toEqual([...expected].sort())
|
||||
})
|
||||
|
||||
it('keeps every directory whose ownership cannot be established', async () => {
|
||||
seedDecisionMatrix()
|
||||
|
||||
@@ -230,51 +161,6 @@ describe('history GC prune decisions', () => {
|
||||
expect(after.has('orphan-unparseable-createdat')).toBe(false)
|
||||
expect(existsSync(join(historyRoot, 'stray-file'))).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses to prune anything when the live set is empty', async () => {
|
||||
seedDecisionMatrix()
|
||||
|
||||
await runHistoryGc(new Set())
|
||||
|
||||
expect(survivingDirs().has('orphan-old')).toBe(true)
|
||||
expect(readdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME))).toEqual([])
|
||||
})
|
||||
|
||||
it('does not throw when the history root does not exist', async () => {
|
||||
rmSync(historyRoot, { recursive: true, force: true })
|
||||
await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('tombstones orphans instead of removing them on the calling thread', async () => {
|
||||
seedDecisionMatrix()
|
||||
|
||||
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
|
||||
|
||||
for (const [dir] of removeHostTreeMock.mock.calls) {
|
||||
expect(dir).toContain(PENDING_DELETE_DIR_NAME)
|
||||
}
|
||||
expect(tombstonedNames().has('orphan-old')).toBe(true)
|
||||
})
|
||||
|
||||
it('drains pre-existing tombstones without scanning them as worktrees', async () => {
|
||||
seedDecisionMatrix()
|
||||
const leftover = join(historyRoot, PENDING_DELETE_DIR_NAME, 'abc123.1700000000000.deadbeef')
|
||||
mkdirSync(leftover, { recursive: true })
|
||||
|
||||
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
|
||||
|
||||
expect(removeHostTreeMock).toHaveBeenCalledWith(expect.stringContaining('abc123.1700000000000'))
|
||||
})
|
||||
|
||||
it('continues the pass after one orphan tombstone fails', async () => {
|
||||
seedDir('orphan-a', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-a`, OLD) })
|
||||
seedDir('orphan-b', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-b`, OLD) })
|
||||
writeFileSync(join(historyRoot, PENDING_DELETE_DIR_NAME), 'not a directory')
|
||||
|
||||
await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined()
|
||||
|
||||
expect(survivingDirs()).toEqual(new Set(['orphan-a', 'orphan-b']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('history GC concurrency behaviour', () => {
|
||||
@@ -312,26 +198,6 @@ describe('history GC concurrency behaviour', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('starts a fresh pass once the previous one has settled', async () => {
|
||||
seedDecisionMatrix()
|
||||
const live = new Set([LIVE_WORKTREE_ID])
|
||||
await runHistoryGc(live)
|
||||
const second = runHistoryGc(live)
|
||||
await expect(second).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('stops an in-flight walk on cancel without pruning', async () => {
|
||||
seedDecisionMatrix()
|
||||
seedBulk(300, 3)
|
||||
const before = survivingDirs()
|
||||
|
||||
const pass = runHistoryGc(new Set([LIVE_WORKTREE_ID]))
|
||||
cancelHistoryGc()
|
||||
await pass
|
||||
|
||||
expect(survivingDirs()).toEqual(before)
|
||||
})
|
||||
|
||||
it('does not run a scheduled pass that was cancelled while resolving live worktrees', async () => {
|
||||
seedDecisionMatrix()
|
||||
vi.useFakeTimers()
|
||||
@@ -350,124 +216,4 @@ describe('history GC concurrency behaviour', () => {
|
||||
|
||||
expect(survivingDirs().has('orphan-old')).toBe(true)
|
||||
})
|
||||
|
||||
it('coalesces duplicate scheduled startup GC calls', async () => {
|
||||
vi.useFakeTimers()
|
||||
const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set<string>())
|
||||
|
||||
scheduleHistoryGc(getLiveWorktreeIds)
|
||||
scheduleHistoryGc(getLiveWorktreeIds)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('history GC races an async walk introduces', () => {
|
||||
it('survives a directory removed while the walk is in flight', async () => {
|
||||
seedDecisionMatrix()
|
||||
seedBulk(300, 5)
|
||||
const live = new Set([LIVE_WORKTREE_ID])
|
||||
const vanishing = ['bulk-11', 'bulk-77', 'bulk-201']
|
||||
|
||||
const pass = runHistoryGc(live)
|
||||
for (const name of vanishing) {
|
||||
rmSync(join(historyRoot, name), { recursive: true, force: true })
|
||||
}
|
||||
await expect(pass).resolves.toBeUndefined()
|
||||
|
||||
expect(survivingDirs().has('live-old')).toBe(true)
|
||||
expect(survivingDirs().has('bulk-1')).toBe(true)
|
||||
for (const name of vanishing) {
|
||||
expect(existsSync(join(historyRoot, name))).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('never prunes a directory whose meta.json is half-written when the walk reads it', async () => {
|
||||
seedDir('being-written', {})
|
||||
seedBulk(200, 5)
|
||||
const live = new Set([LIVE_WORKTREE_ID])
|
||||
|
||||
const pass = runHistoryGc(live)
|
||||
writeFileSync(
|
||||
join(historyRoot, 'being-written', 'meta.json'),
|
||||
`{"worktreeId":"${DEAD_WORKTREE_ID}","created`
|
||||
)
|
||||
await pass
|
||||
|
||||
expect(survivingDirs().has('being-written')).toBe(true)
|
||||
})
|
||||
|
||||
it('prunes a directory whose meta.json arrived after the directory did', async () => {
|
||||
seedDir('late-meta', {})
|
||||
writeFileSync(
|
||||
join(historyRoot, 'late-meta', 'meta.json'),
|
||||
meta(`${DEAD_WORKTREE_ID}-late`, OLD)
|
||||
)
|
||||
|
||||
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
|
||||
|
||||
expect(survivingDirs().has('late-meta')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('history GC main-thread occupancy', () => {
|
||||
it('yields to timers throughout the walk instead of blocking on it', async () => {
|
||||
seedBulk(1_200, 40)
|
||||
const live = new Set([LIVE_WORKTREE_ID])
|
||||
|
||||
const ticks = { sync: 0, async: 0 }
|
||||
let maxAsyncGapMs = 0
|
||||
|
||||
// The synchronous reference walk cannot service timers.
|
||||
const syncTimer = setInterval(() => {
|
||||
ticks.sync += 1
|
||||
}, 4)
|
||||
try {
|
||||
referenceSyncPruneDecisions(historyRoot, live)
|
||||
} finally {
|
||||
clearInterval(syncTimer)
|
||||
}
|
||||
|
||||
const originalYield = eventLoopYield.yieldToEventLoop
|
||||
const servicedYields: boolean[] = []
|
||||
const yieldObserver = vi
|
||||
.spyOn(eventLoopYield, 'yieldToEventLoop')
|
||||
.mockImplementation(async () => {
|
||||
let serviced = false
|
||||
const probe = setTimeout(() => {
|
||||
serviced = true
|
||||
}, 0)
|
||||
try {
|
||||
await originalYield()
|
||||
servicedYields.push(serviced)
|
||||
if (serviced) {
|
||||
ticks.async += 1
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(probe)
|
||||
}
|
||||
})
|
||||
let asyncTimer: ReturnType<typeof setInterval> | undefined
|
||||
try {
|
||||
let last = performance.now()
|
||||
asyncTimer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
maxAsyncGapMs = Math.max(maxAsyncGapMs, now - last - 4)
|
||||
last = now
|
||||
}, 4)
|
||||
await runHistoryGc(live)
|
||||
maxAsyncGapMs = Math.max(maxAsyncGapMs, performance.now() - last - 4)
|
||||
clearInterval(asyncTimer)
|
||||
|
||||
expect(ticks.sync).toBe(0)
|
||||
expect(ticks.async).toBeGreaterThan(5)
|
||||
expect(servicedYields).not.toContain(false)
|
||||
// CI scheduling delays idle timers; detect a whole-walk block rather than small stalls.
|
||||
expect(maxAsyncGapMs).toBeLessThan(2_000)
|
||||
} finally {
|
||||
clearInterval(asyncTimer)
|
||||
yieldObserver.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
cancelPendingHistoryTreeRemovalRetries,
|
||||
deleteWorktreeHistoryDir,
|
||||
flushPendingWorktreeHistoryDeletions,
|
||||
MAX_PENDING_HISTORY_TREE_REMOVALS,
|
||||
HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS,
|
||||
schedulePendingHistoryTreeRemovals
|
||||
} from './terminal-history-deletion'
|
||||
import {
|
||||
@@ -63,118 +61,6 @@ describe('tombstoned history removal retries', () => {
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('admits a bounded tombstone batch and drains the rest from disk', async () => {
|
||||
const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
|
||||
mkdirSync(distroRoot, { recursive: true })
|
||||
const releases: (() => void)[] = []
|
||||
removeHostTreeMock.mockImplementation(
|
||||
(dir) =>
|
||||
new Promise<void>((resolve) =>
|
||||
releases.push(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
for (let index = 0; index < 1_000; index++) {
|
||||
const worktreeId = `repo-1::/path/wsl-${index}`
|
||||
const dir = join(distroRoot, hashWorktreeId(worktreeId))
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, 'meta.json'),
|
||||
JSON.stringify({
|
||||
worktreeId,
|
||||
fishSession: fishHistorySessionName(hashWorktreeId(worktreeId))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
for (let index = 0; index < 1_000; index++) {
|
||||
deleteWorktreeHistoryDir(`repo-1::/path/wsl-${index}`)
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(64)
|
||||
expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(64)
|
||||
expect(releases).toHaveLength(64)
|
||||
|
||||
for (let pass = 0; removeHostTreeMock.mock.calls.length < 1_000; pass++) {
|
||||
expect(pass).toBeLessThan(200)
|
||||
await vi.waitFor(() => expect(releases.length).toBeGreaterThan(0))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await settleTombstoneDirectoryReads()
|
||||
}
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(1_000)
|
||||
expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(1_000)
|
||||
})
|
||||
|
||||
it('caps persistent failures and leaves excess tombstones for a later disk batch', async () => {
|
||||
const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
|
||||
mkdirSync(distroRoot, { recursive: true })
|
||||
// Fish cleanup failures are tolerated; fail tree removal itself to exercise the cap.
|
||||
removeHostTreeMock.mockRejectedValue(new Error('EBUSY'))
|
||||
for (let index = 0; index < 1_000; index++) {
|
||||
const worktreeId = `repo-1::/path/fail-${index}`
|
||||
const dir = join(distroRoot, hashWorktreeId(worktreeId))
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, 'meta.json'),
|
||||
JSON.stringify({
|
||||
worktreeId,
|
||||
fishSession: fishHistorySessionName(hashWorktreeId(worktreeId))
|
||||
})
|
||||
)
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(vi.getTimerCount()).toBe(64)
|
||||
expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(1_000)
|
||||
|
||||
for (const delay of HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS) {
|
||||
await vi.advanceTimersByTimeAsync(delay)
|
||||
expect(vi.getTimerCount()).toBeLessThanOrEqual(64)
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
removeHostTreeMock.mockReset()
|
||||
removeHostTreeMock.mockImplementation(async (dir) => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
schedulePendingHistoryTreeRemovals(distroRoot)
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
expect(removeHostTreeMock.mock.calls.length).toBeGreaterThan(
|
||||
MAX_PENDING_HISTORY_TREE_REMOVALS * (HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS.length + 1)
|
||||
)
|
||||
expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('still removes the history tree when WSL fish cleanup fails', async () => {
|
||||
// WSL terminals can record fish sessions even in distros without fish.
|
||||
const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
|
||||
mkdirSync(distroRoot, { recursive: true })
|
||||
deleteWslFishHistoryFileMock.mockRejectedValue(new Error('fish: command not found'))
|
||||
removeHostTreeMock.mockImplementation(async (dir) => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
const worktreeId = 'repo-1::/path/no-fish'
|
||||
const dir = join(distroRoot, hashWorktreeId(worktreeId))
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, 'meta.json'),
|
||||
JSON.stringify({
|
||||
worktreeId,
|
||||
fishSession: fishHistorySessionName(hashWorktreeId(worktreeId))
|
||||
})
|
||||
)
|
||||
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
|
||||
expect(deleteWslFishHistoryFileMock).toHaveBeenCalled()
|
||||
expect(removeHostTreeMock).toHaveBeenCalled()
|
||||
expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the tombstone while its distro is stopped and finishes once the distro runs', async () => {
|
||||
const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
|
||||
mkdirSync(distroRoot, { recursive: true })
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installFakeAppEnvironment } from '../../../../config/scripts/test-runtime/environment/vitest-host-ports-setup'
|
||||
|
||||
const reads = vi.hoisted(() => ({ count: 0, names: 0, failNext: 0 }))
|
||||
const { removeHostTreeMock } = vi.hoisted(() => ({
|
||||
removeHostTreeMock: vi.fn<(dir: string) => Promise<void>>()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const fsp = await importOriginal<typeof NodeFsPromises>()
|
||||
return {
|
||||
...fsp,
|
||||
readdir: async (...args: Parameters<typeof fsp.readdir>) => {
|
||||
if (!String(args[0]).endsWith('.pending-delete')) {
|
||||
return fsp.readdir(...args)
|
||||
}
|
||||
reads.count++
|
||||
if (reads.failNext > 0) {
|
||||
reads.failNext--
|
||||
throw new Error('EACCES: permission denied, scandir')
|
||||
}
|
||||
const result = await fsp.readdir(...args)
|
||||
reads.names += Array.isArray(result) ? result.length : 0
|
||||
return result
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../filesystem/host-tree-removal', () => ({ removeHostTree: removeHostTreeMock }))
|
||||
|
||||
import {
|
||||
cancelPendingHistoryTreeRemovalRetries,
|
||||
flushPendingWorktreeHistoryDeletions,
|
||||
HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS,
|
||||
MAX_PENDING_HISTORY_TREE_REMOVALS,
|
||||
schedulePendingHistoryTreeRemovals
|
||||
} from './terminal-history-deletion'
|
||||
|
||||
describe('terminal history tombstone scan cost', () => {
|
||||
let userDataDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-scan-cost-'))
|
||||
installFakeAppEnvironment({ getPath: () => userDataDir })
|
||||
reads.count = 0
|
||||
reads.names = 0
|
||||
reads.failNext = 0
|
||||
removeHostTreeMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cancelPendingHistoryTreeRemovalRetries()
|
||||
vi.useRealTimers()
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function seedTombstones(root: string, count: number): void {
|
||||
for (let index = 0; index < count; index++) {
|
||||
mkdirSync(join(root, '.pending-delete', `old-session-${index}`), {
|
||||
recursive: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function holdRemovals(): (() => void)[] {
|
||||
const releases: (() => void)[] = []
|
||||
removeHostTreeMock.mockImplementation(
|
||||
(dir) =>
|
||||
new Promise<void>((resolve) =>
|
||||
releases.push(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
)
|
||||
return releases
|
||||
}
|
||||
|
||||
// Do not let real directory reads land while settling removal promises.
|
||||
async function settleRemovalPromises(): Promise<void> {
|
||||
for (let index = 0; index < 8; index++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(label: string, condition: () => boolean): Promise<void> {
|
||||
for (let index = 0; index < 500; index++) {
|
||||
if (condition()) {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2))
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}`)
|
||||
}
|
||||
|
||||
it('drains 1024 tombstones without re-reading the directory per completion', async () => {
|
||||
const root = join(userDataDir, 'terminal-history')
|
||||
const pendingRoot = join(root, '.pending-delete')
|
||||
seedTombstones(root, 1024)
|
||||
const releases = holdRemovals()
|
||||
|
||||
await schedulePendingHistoryTreeRemovals(root)
|
||||
expect(reads.count).toBe(1)
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS)
|
||||
while (releases.length > 0) {
|
||||
expect(releases.length).toBeLessThanOrEqual(MAX_PENDING_HISTORY_TREE_REMOVALS)
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await settleRemovalPromises()
|
||||
}
|
||||
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(1024)
|
||||
expect(readdirSync(pendingRoot)).toEqual([])
|
||||
expect(reads.count).toBeLessThanOrEqual(8)
|
||||
expect(reads.names).toBeLessThanOrEqual(1024 + MAX_PENDING_HISTORY_TREE_REMOVALS)
|
||||
})
|
||||
|
||||
it('picks up a tombstone created after the queued names were read', async () => {
|
||||
const root = join(userDataDir, 'terminal-history')
|
||||
const pendingRoot = join(root, '.pending-delete')
|
||||
seedTombstones(root, 1)
|
||||
const releases = holdRemovals()
|
||||
|
||||
await schedulePendingHistoryTreeRemovals(root)
|
||||
mkdirSync(join(pendingRoot, 'late-session'))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
|
||||
await waitFor('late tombstone admission', () => removeHostTreeMock.mock.calls.length === 2)
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
expect(readdirSync(pendingRoot)).toEqual([])
|
||||
})
|
||||
|
||||
it('drains both roots within the shared admission cap without per-completion reads', async () => {
|
||||
const nativeRoot = join(userDataDir, 'terminal-history')
|
||||
const wslRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
|
||||
seedTombstones(nativeRoot, 160)
|
||||
seedTombstones(wslRoot, 160)
|
||||
let inFlight = 0
|
||||
let peakInFlight = 0
|
||||
removeHostTreeMock.mockImplementation(async (dir) => {
|
||||
inFlight++
|
||||
peakInFlight = Math.max(peakInFlight, inFlight)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
inFlight--
|
||||
})
|
||||
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(320)
|
||||
expect(peakInFlight).toBeLessThanOrEqual(MAX_PENDING_HISTORY_TREE_REMOVALS)
|
||||
expect(readdirSync(join(nativeRoot, '.pending-delete'))).toEqual([])
|
||||
expect(readdirSync(join(wslRoot, '.pending-delete'))).toEqual([])
|
||||
expect(reads.count).toBeLessThanOrEqual(12)
|
||||
})
|
||||
|
||||
it('keeps draining after a directory read fails mid-drain', async () => {
|
||||
const root = join(userDataDir, 'terminal-history')
|
||||
const pendingRoot = join(root, '.pending-delete')
|
||||
seedTombstones(root, MAX_PENDING_HISTORY_TREE_REMOVALS + 1)
|
||||
const releases = holdRemovals()
|
||||
|
||||
await schedulePendingHistoryTreeRemovals(root)
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS)
|
||||
|
||||
reads.failNext = 1
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await settleRemovalPromises()
|
||||
// The queued 65th removal comes from memory despite the failed refill read.
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS + 1)
|
||||
|
||||
mkdirSync(join(pendingRoot, 'late-after-failed-read'))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await waitFor(
|
||||
'recovery after failed read',
|
||||
() => removeHostTreeMock.mock.calls.length === MAX_PENDING_HISTORY_TREE_REMOVALS + 2
|
||||
)
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
expect(readdirSync(pendingRoot)).toEqual([])
|
||||
})
|
||||
|
||||
it('drops an in-flight refill during fixture cleanup', async () => {
|
||||
const root = join(userDataDir, 'terminal-history')
|
||||
seedTombstones(root, MAX_PENDING_HISTORY_TREE_REMOVALS + 1)
|
||||
const releases = holdRemovals()
|
||||
|
||||
await schedulePendingHistoryTreeRemovals(root)
|
||||
mkdirSync(join(root, '.pending-delete', 'late-session'))
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await settleRemovalPromises()
|
||||
const admittedBeforeCleanup = removeHostTreeMock.mock.calls.length
|
||||
|
||||
cancelPendingHistoryTreeRemovalRetries()
|
||||
await waitFor('the dropped refill to land', () => reads.count >= 2)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(admittedBeforeCleanup)
|
||||
})
|
||||
|
||||
it('preserves delayed failure retries while successful removals replenish the queue', async () => {
|
||||
vi.useFakeTimers()
|
||||
const root = join(userDataDir, 'terminal-history')
|
||||
seedTombstones(root, 130)
|
||||
const failedDir = join(root, '.pending-delete', 'old-session-0')
|
||||
removeHostTreeMock.mockImplementation(async (dir) => {
|
||||
if (dir === failedDir) {
|
||||
throw new Error('EBUSY')
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(130)
|
||||
expect(readdirSync(join(root, '.pending-delete'))).toEqual(['old-session-0'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS[0] - 1)
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(130)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(131)
|
||||
await vi.advanceTimersByTimeAsync(HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS[1])
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(132)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not retry a tombstone past its attempt budget when another root frees slots', async () => {
|
||||
vi.useFakeTimers()
|
||||
const nativeRoot = join(userDataDir, 'terminal-history')
|
||||
const wslRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
|
||||
seedTombstones(nativeRoot, 32)
|
||||
seedTombstones(wslRoot, 130)
|
||||
const releases: (() => void)[] = []
|
||||
const attemptsByDir = new Map<string, number>()
|
||||
removeHostTreeMock.mockImplementation((dir) => {
|
||||
attemptsByDir.set(dir, (attemptsByDir.get(dir) ?? 0) + 1)
|
||||
if (dir.startsWith(wslRoot)) {
|
||||
return Promise.reject(new Error('EBUSY'))
|
||||
}
|
||||
return new Promise<void>((resolve) =>
|
||||
releases.push(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
})
|
||||
await schedulePendingHistoryTreeRemovals(nativeRoot)
|
||||
await schedulePendingHistoryTreeRemovals(wslRoot)
|
||||
|
||||
for (const delay of HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS) {
|
||||
await vi.advanceTimersByTimeAsync(delay)
|
||||
}
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
const maxAttempts = 1 + HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS.length
|
||||
const exhausted = [...attemptsByDir.entries()].filter(([, count]) => count === maxAttempts)
|
||||
expect(exhausted.length).toBeGreaterThan(0)
|
||||
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// Freed slots may admit untried tombstones, but must not restart exhausted ones.
|
||||
for (const count of attemptsByDir.values()) {
|
||||
expect(count).toBeLessThanOrEqual(maxAttempts)
|
||||
}
|
||||
for (const [dir] of exhausted) {
|
||||
expect(attemptsByDir.get(dir)).toBe(maxAttempts)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,731 +0,0 @@
|
||||
import type * as WslPathOwner from '../../../shared/wsl-paths'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { sep } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installFakeAppEnvironment } from '../../../../config/scripts/test-runtime/environment/vitest-host-ports-setup'
|
||||
|
||||
const {
|
||||
existsSyncMock,
|
||||
mkdirSyncMock,
|
||||
writeFileSyncMock,
|
||||
readFileSyncMock,
|
||||
lstatSyncMock,
|
||||
rmSyncMock,
|
||||
renameSyncMock,
|
||||
readdirSyncMock,
|
||||
statSyncMock,
|
||||
openSyncMock,
|
||||
fstatSyncMock,
|
||||
closeSyncMock,
|
||||
getPathMock,
|
||||
rmAsyncMock,
|
||||
deleteWslFishHistoryFileMock
|
||||
} = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
mkdirSyncMock: vi.fn(),
|
||||
writeFileSyncMock: vi.fn(),
|
||||
readFileSyncMock: vi.fn(),
|
||||
lstatSyncMock: vi.fn(),
|
||||
rmSyncMock: vi.fn(),
|
||||
renameSyncMock: vi.fn(),
|
||||
readdirSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
openSyncMock: vi.fn((_path?: string | Buffer) => 1),
|
||||
fstatSyncMock: vi.fn(() => ({
|
||||
dev: 1n,
|
||||
ino: 2n,
|
||||
birthtimeNs: 3n,
|
||||
isFile: () => true,
|
||||
isDirectory: () => true
|
||||
})),
|
||||
closeSyncMock: vi.fn(),
|
||||
getPathMock: vi.fn(),
|
||||
rmAsyncMock: vi.fn(async () => undefined),
|
||||
deleteWslFishHistoryFileMock: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslPathOwner>()),
|
||||
toLinuxPath: toLinuxPathMock
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
mkdirSync: mkdirSyncMock,
|
||||
writeFileSync: writeFileSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
lstatSync: lstatSyncMock,
|
||||
rmSync: rmSyncMock,
|
||||
renameSync: renameSyncMock,
|
||||
readdirSync: readdirSyncMock,
|
||||
statSync: statSyncMock,
|
||||
openSync: openSyncMock,
|
||||
fstatSync: fstatSyncMock,
|
||||
closeSync: closeSyncMock
|
||||
}))
|
||||
|
||||
// Preserve transitive fs/promises imports while replacing the removal and listing entrypoints.
|
||||
vi.mock('node:fs/promises', async () => ({
|
||||
...(await vi.importActual<typeof FsPromises>('node:fs/promises')),
|
||||
rm: rmAsyncMock,
|
||||
readdir: async (path: string) => readdirSyncMock(path) ?? []
|
||||
}))
|
||||
|
||||
const { parseWslPathMock, toLinuxPathMock } = vi.hoisted(() => ({
|
||||
parseWslPathMock: vi.fn((_path: string) => null as { distro: string; linuxPath: string } | null),
|
||||
toLinuxPathMock: vi.fn((p: string) => p)
|
||||
}))
|
||||
|
||||
vi.mock('../../wsl/filesystem/unc-path-access', () => ({
|
||||
parseWslPath: parseWslPathMock
|
||||
}))
|
||||
|
||||
vi.mock('../../wsl/history/wsl-fish-history-cleanup', () => ({
|
||||
deleteWslFishHistoryFile: deleteWslFishHistoryFileMock
|
||||
}))
|
||||
|
||||
import {
|
||||
resolveShellKind,
|
||||
ensureHistoryDir,
|
||||
injectHistoryEnv,
|
||||
injectWslFishHistoryEnv,
|
||||
MAX_HISTORY_META_BYTES,
|
||||
updateHistoryEnvForFallback,
|
||||
type HistoryInjectionResult
|
||||
} from './terminal-history'
|
||||
import { fishHistorySessionName, relayFishHistorySessionName } from './fish-history-session'
|
||||
import { hashWorktreeId } from './terminal-history-id'
|
||||
import {
|
||||
cancelPendingHistoryTreeRemovalRetries,
|
||||
deleteWorktreeHistoryDir,
|
||||
flushPendingWorktreeHistoryDeletions
|
||||
} from './terminal-history-deletion'
|
||||
|
||||
const OTHER_WORKTREE_HASH = hashWorktreeId('repo-1::/path/other-wt')
|
||||
|
||||
describe('terminal-history', () => {
|
||||
afterEach(() => {
|
||||
cancelPendingHistoryTreeRemovalRetries()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mkdirSyncMock.mockReset()
|
||||
// clearAllMocks retains throwing implementations; reset them between tests.
|
||||
renameSyncMock.mockReset()
|
||||
readFileSyncMock.mockReset()
|
||||
readdirSyncMock.mockReset()
|
||||
rmAsyncMock.mockReset()
|
||||
rmAsyncMock.mockResolvedValue(undefined)
|
||||
getPathMock.mockReturnValue('/fake/userData')
|
||||
installFakeAppEnvironment({ getPath: getPathMock })
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
|
||||
openSyncMock.mockImplementation(() => 1)
|
||||
fstatSyncMock.mockReturnValue({
|
||||
dev: 1n,
|
||||
ino: 2n,
|
||||
birthtimeNs: 3n,
|
||||
isFile: () => true,
|
||||
isDirectory: () => true
|
||||
})
|
||||
lstatSyncMock.mockReturnValue({
|
||||
dev: 1n,
|
||||
ino: 2n,
|
||||
birthtimeNs: 3n,
|
||||
size: 100,
|
||||
isDirectory: () => true,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveShellKind', () => {
|
||||
it('detects versioned bash (bash-5.2)', () => {
|
||||
expect(resolveShellKind('/usr/local/bin/bash-5.2')).toBe('bash')
|
||||
})
|
||||
|
||||
it('detects versioned zsh (zsh-5.9)', () => {
|
||||
expect(resolveShellKind('/usr/local/bin/zsh-5.9')).toBe('zsh')
|
||||
})
|
||||
|
||||
it('detects nix-store zsh path', () => {
|
||||
expect(resolveShellKind('/nix/store/abc123/bin/zsh')).toBe('zsh')
|
||||
})
|
||||
|
||||
it('detects pwsh', () => {
|
||||
expect(resolveShellKind('pwsh')).toBe('pwsh')
|
||||
expect(resolveShellKind('pwsh.exe')).toBe('pwsh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hashWorktreeId', () => {
|
||||
it('produces deterministic output for the same input', () => {
|
||||
const a = hashWorktreeId('repo-1::/Users/foo/worktree-a')
|
||||
const b = hashWorktreeId('repo-1::/Users/foo/worktree-a')
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it('produces different output for different inputs', () => {
|
||||
const a = hashWorktreeId('repo-1::/Users/foo/worktree-a')
|
||||
const b = hashWorktreeId('repo-1::/Users/foo/worktree-b')
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
it('produces a 16-character hex string', () => {
|
||||
const hash = hashWorktreeId('repo-1::/Users/foo/worktree-a')
|
||||
expect(hash).toMatch(/^[0-9a-f]{16}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureHistoryDir', () => {
|
||||
it('creates directory with mode 0o700', () => {
|
||||
ensureHistoryDir('abcdef0123456789')
|
||||
expect(mkdirSyncMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/[\\/]fake[\\/]userData[\\/]terminal-history[\\/]abcdef0123456789$/),
|
||||
{ recursive: true, mode: 0o700 }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null on mkdir failure', () => {
|
||||
mkdirSyncMock.mockImplementation(() => {
|
||||
throw new Error('permission denied')
|
||||
})
|
||||
const result = ensureHistoryDir('abcdef0123456789')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('injectHistoryEnv', () => {
|
||||
it('injects HISTFILE for zsh', () => {
|
||||
mkdirSyncMock.mockReturnValue(undefined)
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
|
||||
|
||||
expect(result.shell).toBe('zsh')
|
||||
expect(result.histFile).toContain('terminal-history')
|
||||
expect(result.histFile).toContain('zsh_history')
|
||||
expect(env.HISTFILE).toBe(result.histFile)
|
||||
})
|
||||
|
||||
it('injects HISTFILE for bash', () => {
|
||||
mkdirSyncMock.mockReturnValue(undefined)
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/bash', '/path/wt')
|
||||
|
||||
expect(result.shell).toBe('bash')
|
||||
expect(result.histFile).toContain('bash_history')
|
||||
expect(env.HISTFILE).toBe(result.histFile)
|
||||
})
|
||||
|
||||
it('produces different HISTFILE for different worktreeIds', () => {
|
||||
mkdirSyncMock.mockReturnValue(undefined)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
|
||||
const envA: Record<string, string> = {}
|
||||
injectHistoryEnv(envA, 'repo-1::/path/wt-a', '/bin/zsh', '/path/wt-a')
|
||||
|
||||
const envB: Record<string, string> = {}
|
||||
injectHistoryEnv(envB, 'repo-1::/path/wt-b', '/bin/zsh', '/path/wt-b')
|
||||
|
||||
expect(envA.HISTFILE).not.toBe(envB.HISTFILE)
|
||||
})
|
||||
|
||||
it('preserves caller-provided HISTFILE (check-before-set)', () => {
|
||||
const env: Record<string, string> = { HISTFILE: '/my/custom/histfile' }
|
||||
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
|
||||
|
||||
expect(env.HISTFILE).toBe('/my/custom/histfile')
|
||||
expect(result.histFile).toBeNull()
|
||||
})
|
||||
|
||||
it('does not inject HISTFILE for unknown shells', () => {
|
||||
const env: Record<string, string> = {}
|
||||
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/tcsh', '/path/wt')
|
||||
|
||||
expect(env.HISTFILE).toBeUndefined()
|
||||
expect(result.shell).toBe('unknown')
|
||||
expect(result.histFile).toBeNull()
|
||||
})
|
||||
|
||||
it('does not inject HISTFILE for cmd.exe', () => {
|
||||
const env: Record<string, string> = {}
|
||||
const result = injectHistoryEnv(env, 'repo-1::/path/wt', 'cmd.exe', '/path/wt')
|
||||
|
||||
expect(env.HISTFILE).toBeUndefined()
|
||||
expect(result.shell).toBe('cmd')
|
||||
expect(result.histFile).toBeNull()
|
||||
})
|
||||
|
||||
it('scopes fish by session name instead of HISTFILE, which fish ignores', () => {
|
||||
const env: Record<string, string> = {}
|
||||
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt')
|
||||
|
||||
expect(env.HISTFILE).toBeUndefined()
|
||||
expect(result.shell).toBe('fish')
|
||||
expect(result.fishSession).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt')))
|
||||
expect(env.fish_history).toBe(result.fishSession)
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('meta.json'),
|
||||
expect.stringContaining(`"fishSession":"${result.fishSession}"`),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('records the history DIRECTORY from the SPAWN env, not this process env', () => {
|
||||
const originalDataHome = process.env.XDG_DATA_HOME
|
||||
process.env.XDG_DATA_HOME = ['', 'main', 'process', 'data'].join(sep)
|
||||
try {
|
||||
const env: Record<string, string> = { XDG_DATA_HOME: ['', 'spawn', 'data'].join(sep) }
|
||||
injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt')
|
||||
const meta = JSON.parse(writeFileSyncMock.mock.calls.at(-1)?.[1] as string) as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
|
||||
expect(meta.fishHistoryDir).toBe(['', 'spawn', 'data', 'fish'].join(sep))
|
||||
} finally {
|
||||
if (originalDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = originalDataHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces oversized metadata without parsing it', () => {
|
||||
statSyncMock.mockReturnValue({ isDirectory: () => true, size: MAX_HISTORY_META_BYTES + 1 })
|
||||
const env = { XDG_DATA_HOME: ['', 'active'].join(sep) }
|
||||
|
||||
injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt')
|
||||
|
||||
expect(readFileSyncMock).not.toHaveBeenCalled()
|
||||
const meta = JSON.parse(writeFileSyncMock.mock.calls.at(-1)?.[1] as string) as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
expect(meta.fishHistoryDir).toBe(['', 'active', 'fish'].join(sep))
|
||||
})
|
||||
|
||||
it('gives two fish worktrees different history sessions', () => {
|
||||
const envA: Record<string, string> = {}
|
||||
injectHistoryEnv(envA, 'repo-1::/path/wt-a', '/usr/bin/fish', '/path/wt-a')
|
||||
const envB: Record<string, string> = {}
|
||||
injectHistoryEnv(envB, 'repo-1::/path/wt-b', '/usr/bin/fish', '/path/wt-b')
|
||||
|
||||
expect(envA.fish_history).not.toBe(envB.fish_history)
|
||||
})
|
||||
|
||||
it('drops an ORCA_HISTFILE inherited from a parent Orca PTY', () => {
|
||||
const env: Record<string, string> = {
|
||||
ORCA_HISTFILE: ['', 'other', 'wt', 'zsh_history'].join(sep)
|
||||
}
|
||||
|
||||
injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
|
||||
|
||||
expect(env.ORCA_HISTFILE).toBe(env.HISTFILE)
|
||||
expect(env.ORCA_HISTFILE).not.toContain('other')
|
||||
})
|
||||
|
||||
it('drops an inherited ORCA_HISTFILE even when it injects nothing', () => {
|
||||
const env: Record<string, string> = {
|
||||
HISTFILE: ['', 'mine', 'zsh_history'].join(sep),
|
||||
ORCA_HISTFILE: ['', 'other', 'wt', 'zsh_history'].join(sep)
|
||||
}
|
||||
|
||||
injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
|
||||
|
||||
expect(env.HISTFILE).toBe(['', 'mine', 'zsh_history'].join(sep))
|
||||
expect(env.ORCA_HISTFILE).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'desktop',
|
||||
['', 'fake', 'userData', 'terminal-history', OTHER_WORKTREE_HASH, 'zsh_history'].join(sep)
|
||||
],
|
||||
[
|
||||
'relay',
|
||||
[
|
||||
'',
|
||||
'home',
|
||||
'me',
|
||||
'.orca-remote',
|
||||
'terminal-history',
|
||||
`${OTHER_WORKTREE_HASH}-zsh_history`
|
||||
].join(sep)
|
||||
]
|
||||
])('drops a %s HISTFILE inherited from a parent Orca pane', (_kind, inherited) => {
|
||||
const env: Record<string, string> = { HISTFILE: inherited }
|
||||
|
||||
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
|
||||
|
||||
expect(result.histFile).toBe(env.HISTFILE)
|
||||
expect(env.HISTFILE).toContain(hashWorktreeId('repo-1::/path/wt'))
|
||||
expect(env.HISTFILE).not.toContain(OTHER_WORKTREE_HASH)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an ordinary path', ['', 'home', 'me', '.zsh_history'].join(sep)],
|
||||
// Orca mints absolute paths; a matching relative path belongs to the user.
|
||||
[
|
||||
'a relative path of Orca’s shape',
|
||||
['terminal-history', OTHER_WORKTREE_HASH, 'zsh_history'].join(sep)
|
||||
]
|
||||
])('preserves %s the user set as HISTFILE', (_kind, histFile) => {
|
||||
const env: Record<string, string> = { HISTFILE: histFile }
|
||||
|
||||
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
|
||||
|
||||
expect(env.HISTFILE).toBe(histFile)
|
||||
expect(result.histFile).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves a caller-supplied fish_history', () => {
|
||||
const env: Record<string, string> = { fish_history: 'mine' }
|
||||
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt')
|
||||
|
||||
expect(env.fish_history).toBe('mine')
|
||||
expect(result.fishSession).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['desktop', fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))],
|
||||
['relay', relayFishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))]
|
||||
])('replaces a %s fish_history inherited from a parent Orca', (_kind, inherited) => {
|
||||
const env: Record<string, string> = { fish_history: inherited }
|
||||
|
||||
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt')
|
||||
|
||||
expect(result.fishSession).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt')))
|
||||
expect(env.fish_history).toBe(result.fishSession)
|
||||
})
|
||||
|
||||
it('drops an inherited fish_history even when the shell is not fish', () => {
|
||||
const env: Record<string, string> = {
|
||||
fish_history: fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))
|
||||
}
|
||||
|
||||
injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
|
||||
|
||||
expect(env.fish_history).toBeUndefined()
|
||||
})
|
||||
|
||||
it('degrades gracefully when directory creation fails', () => {
|
||||
mkdirSyncMock.mockImplementation(() => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
|
||||
|
||||
expect(env.HISTFILE).toBeUndefined()
|
||||
expect(result.histFile).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('injectWslFishHistoryEnv', () => {
|
||||
it('replaces an inherited Orca fish_history with this worktree session', () => {
|
||||
const env: Record<string, string> = {
|
||||
fish_history: fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))
|
||||
}
|
||||
|
||||
const session = injectWslFishHistoryEnv(env, 'repo-1::/path/wt', 'Ubuntu')
|
||||
|
||||
expect(session).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt')))
|
||||
expect(env.fish_history).toBe(session)
|
||||
})
|
||||
|
||||
it('preserves a caller-supplied fish_history', () => {
|
||||
const env: Record<string, string> = { fish_history: 'mine' }
|
||||
|
||||
expect(injectWslFishHistoryEnv(env, 'repo-1::/path/wt', 'Ubuntu')).toBeNull()
|
||||
expect(env.fish_history).toBe('mine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateHistoryEnvForFallback', () => {
|
||||
const zshInjection = (): HistoryInjectionResult => ({
|
||||
shell: 'zsh',
|
||||
histFile: '/fake/userData/terminal-history/abc123/zsh_history',
|
||||
fishSession: null,
|
||||
historyDir: '/fake/userData/terminal-history/abc123'
|
||||
})
|
||||
|
||||
it('updates HISTFILE to match fallback shell', () => {
|
||||
const env: Record<string, string> = {
|
||||
HISTFILE: '/fake/userData/terminal-history/abc123/zsh_history'
|
||||
}
|
||||
updateHistoryEnvForFallback(env, '/bin/bash', zshInjection())
|
||||
expect(env.HISTFILE).toBe('/fake/userData/terminal-history/abc123/bash_history')
|
||||
})
|
||||
|
||||
it('removes HISTFILE for unknown fallback shell', () => {
|
||||
const env: Record<string, string> = {
|
||||
HISTFILE: '/fake/userData/terminal-history/abc123/zsh_history'
|
||||
}
|
||||
updateHistoryEnvForFallback(env, '/bin/sh', zshInjection())
|
||||
expect(env.HISTFILE).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is a no-op when nothing was injected', () => {
|
||||
const env: Record<string, string> = {}
|
||||
updateHistoryEnvForFallback(env, '/bin/bash', {
|
||||
shell: 'unknown',
|
||||
histFile: null,
|
||||
fishSession: null,
|
||||
historyDir: null
|
||||
})
|
||||
expect(env.HISTFILE).toBeUndefined()
|
||||
})
|
||||
|
||||
it('swaps an injected fish session for the fallback shell HISTFILE', () => {
|
||||
const env: Record<string, string> = { fish_history: 'orca_abc123' }
|
||||
updateHistoryEnvForFallback(env, '/bin/bash', {
|
||||
shell: 'fish',
|
||||
histFile: null,
|
||||
fishSession: 'orca_abc123',
|
||||
historyDir: '/fake/userData/terminal-history/abc123'
|
||||
})
|
||||
expect(env.fish_history).toBeUndefined()
|
||||
expect(env.HISTFILE).toBe('/fake/userData/terminal-history/abc123/bash_history')
|
||||
})
|
||||
|
||||
it('keeps a caller-supplied fish_history the injection never claimed', () => {
|
||||
const env: Record<string, string> = { fish_history: 'mine' }
|
||||
updateHistoryEnvForFallback(env, '/bin/bash', zshInjection())
|
||||
expect(env.fish_history).toBe('mine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteWorktreeHistoryDir', () => {
|
||||
const worktreeId = 'repo-1::/path/wt'
|
||||
const session = fishHistorySessionName(hashWorktreeId(worktreeId))
|
||||
const historyFilename = `${session}_history`
|
||||
|
||||
it('tombstones then async-removes the history directory without recursive rmSync', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
deleteWorktreeHistoryDir('repo-1::/path/wt')
|
||||
expect(renameSyncMock).toHaveBeenCalled()
|
||||
expect(rmSyncMock).not.toHaveBeenCalled()
|
||||
expect(rmAsyncMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.pending-delete'),
|
||||
expect.objectContaining({ recursive: true, force: true })
|
||||
)
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
})
|
||||
|
||||
it('deletes the fish history file in the directory meta.json recorded', async () => {
|
||||
const recordedDir = ['', 'spawn', 'data', 'fish'].join(sep)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
lstatSyncMock.mockReturnValue({ isFile: () => true })
|
||||
readFileSyncMock.mockReturnValue(
|
||||
JSON.stringify({ worktreeId, fishSession: session, fishHistoryDir: recordedDir })
|
||||
)
|
||||
|
||||
deleteWorktreeHistoryDir('repo-1::/path/wt')
|
||||
|
||||
expect(rmSyncMock).toHaveBeenCalledWith([recordedDir, historyFilename].join(sep))
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
})
|
||||
|
||||
it('also tries this process fish dir, for meta written before that field', async () => {
|
||||
const originalDataHome = process.env.XDG_DATA_HOME
|
||||
process.env.XDG_DATA_HOME = ['', 'main', 'data'].join(sep)
|
||||
try {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
lstatSyncMock.mockReturnValue({ isFile: () => true })
|
||||
readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session }))
|
||||
|
||||
deleteWorktreeHistoryDir('repo-1::/path/wt')
|
||||
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(
|
||||
['', 'main', 'data', 'fish', historyFilename].join(sep)
|
||||
)
|
||||
} finally {
|
||||
if (originalDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = originalDataHome
|
||||
}
|
||||
}
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
})
|
||||
|
||||
it('ignores a fishSession that does not belong to this history directory', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
lstatSyncMock.mockReturnValue({ isFile: () => true })
|
||||
readFileSyncMock.mockReturnValue(
|
||||
JSON.stringify({ worktreeId, fishSession: 'orca_deadbeefdeadbeef' })
|
||||
)
|
||||
|
||||
deleteWorktreeHistoryDir('repo-1::/path/wt')
|
||||
|
||||
expect(rmSyncMock).not.toHaveBeenCalledWith(expect.stringContaining('orca_deadbeefdeadbeef'))
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
})
|
||||
|
||||
it('refuses to unlink a fish history path that is not a regular file', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
lstatSyncMock.mockReturnValue({ isFile: () => false })
|
||||
readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session }))
|
||||
|
||||
deleteWorktreeHistoryDir('repo-1::/path/wt')
|
||||
|
||||
expect(rmSyncMock).not.toHaveBeenCalled()
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
})
|
||||
|
||||
it('cleans WSL Fish history through the owning distro before removing metadata', async () => {
|
||||
readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session }))
|
||||
readdirSyncMock.mockImplementation((path: string) => {
|
||||
if (path.endsWith('terminal-history-wsl')) {
|
||||
return ['Ubuntu']
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
|
||||
expect(deleteWslFishHistoryFileMock).toHaveBeenCalledWith('Ubuntu', session)
|
||||
})
|
||||
|
||||
it('leaves the live directory alone when the tombstone rename fails', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
renameSyncMock.mockImplementation(() => {
|
||||
throw new Error('permission denied')
|
||||
})
|
||||
|
||||
expect(() => deleteWorktreeHistoryDir('repo-1::/path/wt')).not.toThrow()
|
||||
|
||||
// A recreated worktree can own the live path before async rm lands; leave it for GC.
|
||||
expect(rmAsyncMock).not.toHaveBeenCalled()
|
||||
expect(rmSyncMock).not.toHaveBeenCalled()
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
})
|
||||
|
||||
it('does not throw when the async removal itself fails', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
rmAsyncMock.mockRejectedValueOnce(new Error('async fail'))
|
||||
expect(() => deleteWorktreeHistoryDir('repo-1::/path/wt')).not.toThrow()
|
||||
await expect(flushPendingWorktreeHistoryDeletions()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('retries pending tombstones on flush after a failed async rm (app-quit durability)', async () => {
|
||||
let leftoverTombstonePresent = true
|
||||
existsSyncMock.mockImplementation((p: string) => {
|
||||
const path = String(p)
|
||||
if (path.includes('.pending-delete') && !path.endsWith('.pending-delete')) {
|
||||
return true
|
||||
}
|
||||
if (path.endsWith('terminal-history') || path.endsWith('.pending-delete')) {
|
||||
return true
|
||||
}
|
||||
return path.includes(hashWorktreeId('repo-1::/path/wt'))
|
||||
})
|
||||
readdirSyncMock.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('.pending-delete')) {
|
||||
return leftoverTombstonePresent ? ['leftover-tombstone'] : []
|
||||
}
|
||||
return []
|
||||
})
|
||||
rmAsyncMock.mockImplementation(async () => {
|
||||
leftoverTombstonePresent = false
|
||||
})
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
expect(rmAsyncMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('leftover-tombstone'),
|
||||
expect.objectContaining({ recursive: true, force: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WSL path conversion', () => {
|
||||
it('converts HISTFILE to Linux path for WSL cwd', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
try {
|
||||
parseWslPathMock.mockReturnValue({ distro: 'Ubuntu', linuxPath: '/home/user/project' })
|
||||
toLinuxPathMock.mockReturnValue(
|
||||
'/mnt/c/Users/user/AppData/Roaming/Orca/terminal-history-wsl/Ubuntu/abc123/bash_history'
|
||||
)
|
||||
mkdirSyncMock.mockReturnValue(undefined)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
const result = injectHistoryEnv(
|
||||
env,
|
||||
'repo-1::/wsl/path',
|
||||
'/bin/bash',
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\user\\project'
|
||||
)
|
||||
|
||||
expect(toLinuxPathMock).toHaveBeenCalled()
|
||||
expect(result.histFile).toContain('/mnt/')
|
||||
expect(env.HISTFILE).toBe(result.histFile)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('stores WSL history under terminal-history-wsl/<distro>/', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
try {
|
||||
parseWslPathMock.mockReturnValue({ distro: 'Ubuntu', linuxPath: '/home/user' })
|
||||
toLinuxPathMock.mockImplementation((p: string) => p)
|
||||
mkdirSyncMock.mockReturnValue(undefined)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
injectHistoryEnv(
|
||||
env,
|
||||
'repo-1::/wsl/path',
|
||||
'/bin/bash',
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\user'
|
||||
)
|
||||
|
||||
expect(mkdirSyncMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/[\\/]terminal-history-wsl[\\/]Ubuntu[\\/]/),
|
||||
expect.any(Object)
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the project WSL distro hint when cwd is a Windows path', () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
try {
|
||||
parseWslPathMock.mockReturnValue(null)
|
||||
toLinuxPathMock.mockImplementation((p: string) => p.replace(/^C:\\/i, '/mnt/c/'))
|
||||
mkdirSyncMock.mockReturnValue(undefined)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
getPathMock.mockReturnValue('C:\\Users\\alice\\AppData\\Roaming\\Orca')
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
const result = injectHistoryEnv(env, 'repo-1::C:\\repo', '/bin/bash', 'C:\\repo', {
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
|
||||
expect(mkdirSyncMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/[\\/]terminal-history-wsl[\\/]Ubuntu[\\/]/),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(toLinuxPathMock).toHaveBeenCalled()
|
||||
expect(result.histFile).toMatch(/^\/mnt\/c\//)
|
||||
expect(env.HISTFILE).toBe(result.histFile)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec'
|
||||
import {
|
||||
getPosixPtyForegroundGroup,
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
const { runProcessMock } = vi.hoisted(() => ({
|
||||
runProcessMock: vi.fn<(spec: ProcessSpec) => Promise<ProcessResult>>()
|
||||
}))
|
||||
const pidArg = (spec: ProcessSpec): string => {
|
||||
const args = spec.args ?? []
|
||||
return args[args.indexOf('-p') + 1]
|
||||
}
|
||||
vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock }))
|
||||
|
||||
const psResult = (stdout: string): ProcessResult => ({
|
||||
@@ -26,10 +22,6 @@ const psResult = (stdout: string): ProcessResult => ({
|
||||
const macosTable = ['84644 84985 ttys318', ' 4242 4242 ttys002'].join('\n')
|
||||
|
||||
describe('getPosixPtyForegroundGroup', () => {
|
||||
it('accepts a Linux pts device name', () => {
|
||||
expect(getPosixPtyForegroundGroup('900 931 pts/3', 900, '/dev/pts/3', 4242)).toBe(931)
|
||||
})
|
||||
|
||||
it('refuses when the pid now answers for a different tty', () => {
|
||||
// A recycled root PID must not signal another terminal's group.
|
||||
expect(getPosixPtyForegroundGroup(macosTable, 84644, '/dev/ttys999', 4242)).toBeNull()
|
||||
@@ -40,16 +32,6 @@ describe('getPosixPtyForegroundGroup', () => {
|
||||
const shared = ['84644 84985 ttys318', '4242 84985 ttys318'].join('\n')
|
||||
expect(getPosixPtyForegroundGroup(shared, 84644, '/dev/ttys318', 4242)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a detached tty or an unowned foreground group', () => {
|
||||
expect(getPosixPtyForegroundGroup('84644 84985 ??', 84644, '/dev/ttys318', 4242)).toBeNull()
|
||||
expect(getPosixPtyForegroundGroup('84644 -1 ttys318', 84644, '/dev/ttys318', 4242)).toBeNull()
|
||||
expect(getPosixPtyForegroundGroup('84644 1 ttys318', 84644, '/dev/ttys318', 4242)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses when the root pid is absent from the table', () => {
|
||||
expect(getPosixPtyForegroundGroup('4242 4242 ttys002', 84644, '/dev/ttys318', 4242)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('signalPosixPtyForegroundGroup', () => {
|
||||
@@ -70,157 +52,6 @@ describe('signalPosixPtyForegroundGroup', () => {
|
||||
kill.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the root pid on Windows', async () => {
|
||||
// Windows rejects negative PIDs and treats SIGTERM/SIGINT as termination.
|
||||
const fallback = vi.fn()
|
||||
await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, {
|
||||
platform: 'win32',
|
||||
currentPid: 4242,
|
||||
readProcessTable: table
|
||||
})
|
||||
expect(fallback).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls back when the slave device name is unknown', async () => {
|
||||
const fallback = vi.fn()
|
||||
await signalPosixPtyForegroundGroup(84644, undefined, 'SIGWINCH', fallback, {
|
||||
platform: 'darwin',
|
||||
currentPid: 4242,
|
||||
readProcessTable: table
|
||||
})
|
||||
expect(fallback).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls back when the process table cannot be read', async () => {
|
||||
const fallback = vi.fn()
|
||||
await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, {
|
||||
platform: 'darwin',
|
||||
currentPid: 4242,
|
||||
readProcessTable: async () => {
|
||||
throw new Error('ps timed out')
|
||||
}
|
||||
})
|
||||
expect(fallback).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('treats a vanished foreground group as done rather than falling back', async () => {
|
||||
// ESRCH means the captured target vanished; root fallback would be stale too.
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
const error = new Error('no such process') as NodeJS.ErrnoException
|
||||
error.code = 'ESRCH'
|
||||
throw error
|
||||
})
|
||||
const fallback = vi.fn()
|
||||
try {
|
||||
await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, {
|
||||
platform: 'darwin',
|
||||
currentPid: 4242,
|
||||
readProcessTable: table
|
||||
})
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back when foreground group signalling fails', async () => {
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
const error = new Error('operation not permitted') as NodeJS.ErrnoException
|
||||
error.code = 'EPERM'
|
||||
throw error
|
||||
})
|
||||
const fallback = vi.fn()
|
||||
try {
|
||||
await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, {
|
||||
platform: 'darwin',
|
||||
currentPid: 4242,
|
||||
readProcessTable: table
|
||||
})
|
||||
expect(fallback).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('process table lookup', () => {
|
||||
beforeEach(() => {
|
||||
resetPosixPtyForegroundGroupOwnRowCache()
|
||||
})
|
||||
|
||||
it('asks ps for one pid at a time', async () => {
|
||||
// macOS PID lists scan the whole table (~3.6s vs ~3ms), silently timing out into root-only delivery.
|
||||
runProcessMock.mockReset()
|
||||
runProcessMock.mockResolvedValue(psResult('84644 84985 ttys318'))
|
||||
// Mock group signalling so fixture PGIDs cannot target real host processes.
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
||||
|
||||
try {
|
||||
await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', vi.fn(), {
|
||||
platform: 'darwin',
|
||||
currentPid: 4242
|
||||
})
|
||||
|
||||
expect(runProcessMock).toHaveBeenCalled()
|
||||
let timeoutBudget = 0
|
||||
for (const [spec] of runProcessMock.mock.calls) {
|
||||
expect(pidArg(spec)).toMatch(/^\d+$/)
|
||||
expect(spec.args?.filter((arg) => arg === '-p')).toHaveLength(1)
|
||||
timeoutBudget += spec.timeoutMs ?? Infinity
|
||||
}
|
||||
expect(timeoutBudget).toBeLessThanOrEqual(250)
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('forks ps once per pane after the first SIGWINCH of the process', async () => {
|
||||
// Reuse Orca's own TTY row: repeated ps forks doubled the per-pane resize cost.
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
||||
const panes = [
|
||||
{ rootPid: 900, tty: 'ttys301' },
|
||||
{ rootPid: 901, tty: 'ttys302' },
|
||||
{ rootPid: 902, tty: 'ttys303' },
|
||||
{ rootPid: 903, tty: 'ttys304' }
|
||||
]
|
||||
|
||||
try {
|
||||
runProcessMock.mockReset()
|
||||
runProcessMock.mockImplementation(async (spec) => {
|
||||
const pid = Number(pidArg(spec))
|
||||
if (pid === 4242) {
|
||||
return psResult('4242 4242 ttys002')
|
||||
}
|
||||
const pane = panes.find((entry) => entry.rootPid === pid)
|
||||
return psResult(pane ? `${pane.rootPid} ${pane.rootPid + 50} ${pane.tty}` : '')
|
||||
})
|
||||
|
||||
for (const pane of panes) {
|
||||
// Hidden restore and reattach each signal once per pane.
|
||||
for (let signalIndex = 0; signalIndex < 2; signalIndex += 1) {
|
||||
await signalPosixPtyForegroundGroup(
|
||||
pane.rootPid,
|
||||
`/dev/${pane.tty}`,
|
||||
'SIGWINCH',
|
||||
vi.fn(),
|
||||
{
|
||||
platform: 'darwin',
|
||||
currentPid: 4242
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pidArgs = runProcessMock.mock.calls.map(([spec]) => Number(pidArg(spec)))
|
||||
expect(pidArgs.filter((pid) => pid === 4242)).toHaveLength(1)
|
||||
expect(pidArgs).toHaveLength(9)
|
||||
expect(kill).toHaveBeenCalledTimes(8)
|
||||
} finally {
|
||||
runProcessMock.mockReset()
|
||||
kill.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('shares the native master group on resize with own-TTY and lifetime protection', async () => {
|
||||
|
||||
@@ -3,8 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
|
||||
import type { ProcessResult } from '@orca/process-host/process-spec'
|
||||
|
||||
const {
|
||||
readFileMock,
|
||||
readFileSyncMock,
|
||||
@@ -33,19 +31,12 @@ vi.mock('@orca/process-host', () => ({
|
||||
runProcess: runProcessMock,
|
||||
runProcessSync: runProcessSyncMock
|
||||
}))
|
||||
|
||||
import {
|
||||
forceKillPosixPtyProcessGroups,
|
||||
forceKillPosixPtyProcessGroupsSync,
|
||||
getPosixPtyProcessGroups,
|
||||
isPosixPtyRootStopped,
|
||||
signalPosixPtyProcessGroups
|
||||
} from './posix-pty-process-groups'
|
||||
import {
|
||||
parseLinuxProcStatSessionId,
|
||||
readPosixPtyProcessTable,
|
||||
resetPosixPtyProcessTableDialectForTests
|
||||
} from './posix-pty-process-table'
|
||||
import { resetPosixPtyProcessTableDialectForTests } from './posix-pty-process-table'
|
||||
|
||||
// `/proc/<pid>/stat` fixture: fields after comm are state, ppid, pgrp, session, tty_nr, ...
|
||||
const procStat = (pid: number, session: number, comm = 'bash'): string =>
|
||||
@@ -79,12 +70,6 @@ const TABLE = `
|
||||
200 200 ttys002
|
||||
300 300 ??
|
||||
`
|
||||
|
||||
const ALL_PROCESS_ARGS = [
|
||||
'-e',
|
||||
'-o',
|
||||
'pid=PROCESS_ID,pgid=PROCESS_GID,tty=TERMINAL_DEVICE_NUMBER,stat=PROCESS_STATE'
|
||||
]
|
||||
const BUSYBOX_TABLE = `
|
||||
PROCESS_ID PROCESS_GID TERMINAL_DEVICE_NUMBER
|
||||
100 100 136,100
|
||||
@@ -93,204 +78,8 @@ PROCESS_ID PROCESS_GID TERMINAL_DEVICE_NUMBER
|
||||
201 201 136,10
|
||||
999 999 ?
|
||||
`
|
||||
const unsupportedSelection = (stderr = 'ps: unrecognized option: p\n'): ProcessResult => ({
|
||||
code: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr,
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
describe('ps selection compatibility', () => {
|
||||
it.each([
|
||||
['p', 'ps: unrecognized option: p\nBusyBox v1.37\nUsage: ps'],
|
||||
['p', "ps: invalid option -- 'p'\n"],
|
||||
['p', 'ps: illegal option -- p\n'],
|
||||
['t', 'ps: unrecognized option: t\n']
|
||||
])(
|
||||
'falls back after a rejected %s selector and caches only the dialect (%s)',
|
||||
async (option, stderr) => {
|
||||
if (option === 't') {
|
||||
runProcessMock.mockResolvedValueOnce({ code: 0, stdout: '100 100 pts/100' })
|
||||
}
|
||||
runProcessMock
|
||||
.mockResolvedValueOnce(unsupportedSelection(stderr))
|
||||
.mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE })
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
stdout: BUSYBOX_TABLE.replace('201 201 136,10', '202 202 136,10')
|
||||
})
|
||||
|
||||
const first = await readPosixPtyProcessTable(100)
|
||||
const second = await readPosixPtyProcessTable(200)
|
||||
expect(first).toBe(BUSYBOX_TABLE)
|
||||
expect(getPosixPtyProcessGroups(first, 100, 999)).toEqual([101, 100])
|
||||
expect(getPosixPtyProcessGroups(second, 200, 999)).toEqual([202, 200])
|
||||
expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([
|
||||
['-p', '100', '-o', 'pid=,pgid=,tty=,stat='],
|
||||
...(option === 't' ? [['-t', 'pts/100', '-o', 'pid=,pgid=,tty=,stat=']] : []),
|
||||
ALL_PROCESS_ARGS,
|
||||
ALL_PROCESS_ARGS
|
||||
])
|
||||
expect(
|
||||
runProcessMock.mock.calls.every(
|
||||
([spec]) => spec.maxOutputBytes === 1048576 && spec.timeoutMs === 1000
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('shares the initial unsupported probe across concurrent callers and cancels waiting independently', async () => {
|
||||
let resolveProbe: (result: ProcessResult) => void = () => {}
|
||||
runProcessMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<ProcessResult>((resolve) => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValue({ code: 0, stdout: BUSYBOX_TABLE })
|
||||
const first = readPosixPtyProcessTable(100)
|
||||
const second = readPosixPtyProcessTable(200)
|
||||
const controller = new AbortController()
|
||||
const cancelled = readPosixPtyProcessTable(300, controller.signal)
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
controller.abort()
|
||||
await expect(cancelled).rejects.toThrow()
|
||||
resolveProbe(unsupportedSelection())
|
||||
|
||||
expect(getPosixPtyProcessGroups(await first, 100, 999)).toEqual([101, 100])
|
||||
expect(getPosixPtyProcessGroups(await second, 200, 999)).toEqual([201, 200])
|
||||
expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([
|
||||
['-p', '100', '-o', 'pid=,pgid=,tty=,stat='],
|
||||
ALL_PROCESS_ARGS,
|
||||
ALL_PROCESS_ARGS
|
||||
])
|
||||
})
|
||||
|
||||
it('releases every waiter after a failed probe instead of chaining another probe per caller', async () => {
|
||||
// A wedged ps would otherwise stack one timeout per concurrent force-kill.
|
||||
const probe = Promise.withResolvers<ProcessResult>()
|
||||
const queries: ((result: ProcessResult) => void)[] = []
|
||||
runProcessMock
|
||||
.mockImplementationOnce(() => probe.promise)
|
||||
.mockImplementation(() => new Promise((resolve) => queries.push(resolve)))
|
||||
const first = readPosixPtyProcessTable(100)
|
||||
const second = readPosixPtyProcessTable(200)
|
||||
const third = readPosixPtyProcessTable(300)
|
||||
|
||||
probe.resolve({ code: null, signal: 'SIGTERM', stdout: '', stderr: '', timedOut: true })
|
||||
await expect(first).rejects.toThrow('unavailable')
|
||||
await vi.waitFor(() => expect(runProcessMock).toHaveBeenCalledTimes(3))
|
||||
|
||||
for (const resolve of queries) {
|
||||
resolve({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false })
|
||||
}
|
||||
await expect(Promise.all([second, third])).resolves.toEqual(['', ''])
|
||||
})
|
||||
|
||||
it('uses the cached async dialect for synchronous teardown with fresh membership', async () => {
|
||||
runProcessMock
|
||||
.mockResolvedValueOnce(unsupportedSelection())
|
||||
.mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE })
|
||||
await readPosixPtyProcessTable(100)
|
||||
runProcessSyncMock.mockReturnValue({
|
||||
code: 0,
|
||||
stdout: BUSYBOX_TABLE.replace('101 101 136,100', '102 102 136,100')
|
||||
})
|
||||
const signalProcessGroup = vi.fn()
|
||||
const fallback = vi.fn()
|
||||
forceKillPosixPtyProcessGroupsSync(100, fallback, {
|
||||
platform: 'linux',
|
||||
currentPid: 999,
|
||||
signalProcessGroup
|
||||
})
|
||||
expect(runProcessSyncMock.mock.calls.map(([spec]) => spec.args)).toEqual([ALL_PROCESS_ARGS])
|
||||
expect(signalProcessGroup.mock.calls).toEqual([[102], [100]])
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discovers unsupported selection during teardown and shares it with async readers', async () => {
|
||||
runProcessSyncMock
|
||||
.mockReturnValueOnce(unsupportedSelection())
|
||||
.mockReturnValueOnce({ code: 0, stdout: BUSYBOX_TABLE })
|
||||
const signalProcessGroup = vi.fn()
|
||||
const fallback = vi.fn()
|
||||
forceKillPosixPtyProcessGroupsSync(100, fallback, {
|
||||
platform: 'linux',
|
||||
currentPid: 999,
|
||||
signalProcessGroup
|
||||
})
|
||||
expect(signalProcessGroup.mock.calls).toEqual([[101], [100]])
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
runProcessMock.mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE })
|
||||
await readPosixPtyProcessTable(200)
|
||||
expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ALL_PROCESS_ARGS])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ stderr: 'ps: permission denied' },
|
||||
{ stderr: 'ps: unrecognized option: o' },
|
||||
{ stderr: 'ps: unrecognized option: t' },
|
||||
{ timedOut: true },
|
||||
{ outputTruncated: true },
|
||||
{ code: null, signal: 'SIGTERM' }
|
||||
])('does not turn an unrelated failure into a full-host scan: %j', async (failure) => {
|
||||
runProcessMock.mockResolvedValueOnce({ ...unsupportedSelection(), ...failure })
|
||||
await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable')
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
runProcessMock
|
||||
.mockResolvedValueOnce({ code: 0, stdout: '100 100 ttys001' })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: TABLE })
|
||||
await readPosixPtyProcessTable(100)
|
||||
expect(runProcessMock.mock.calls[1][0].args).toEqual([
|
||||
'-p',
|
||||
'100',
|
||||
'-o',
|
||||
'pid=,pgid=,tty=,stat='
|
||||
])
|
||||
})
|
||||
|
||||
it.each([{ code: 1 }, { code: 0, timedOut: true }, { code: 0, outputTruncated: true }])(
|
||||
'rejects incomplete fallback snapshots for async discovery and sync teardown: %j',
|
||||
async (failure) => {
|
||||
runProcessMock
|
||||
.mockResolvedValueOnce(unsupportedSelection())
|
||||
.mockResolvedValueOnce({ stdout: BUSYBOX_TABLE, ...failure })
|
||||
await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable')
|
||||
runProcessSyncMock.mockReturnValue({ stdout: BUSYBOX_TABLE, ...failure })
|
||||
const signalProcessGroup = vi.fn()
|
||||
const fallback = vi.fn()
|
||||
forceKillPosixPtyProcessGroupsSync(100, fallback, {
|
||||
platform: 'linux',
|
||||
currentPid: 999,
|
||||
signalProcessGroup
|
||||
})
|
||||
expect(fallback).toHaveBeenCalledOnce()
|
||||
expect(signalProcessGroup).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not cache a rejected selector if the caller has already cancelled', async () => {
|
||||
const controller = new AbortController()
|
||||
runProcessMock.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
return unsupportedSelection()
|
||||
})
|
||||
await expect(readPosixPtyProcessTable(100, controller.signal)).rejects.toThrow()
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
runProcessMock.mockResolvedValueOnce({ code: 0, stdout: '100 100 ?' })
|
||||
await readPosixPtyProcessTable(100)
|
||||
expect(runProcessMock.mock.calls[1][0].args[0]).toBe('-p')
|
||||
})
|
||||
|
||||
it.each(['?', '??', '-', '0', '0,0'])(
|
||||
'refuses to group processes without a controlling terminal (%s)',
|
||||
(tty) => {
|
||||
expect(getPosixPtyProcessGroups(`100 100 ${tty}\n101 101 ${tty}`, 100, 999)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves full numeric terminal identity and the daemon terminal guard', () => {
|
||||
expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 100, 999)).toEqual([101, 100])
|
||||
expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 200, 999)).toEqual([201, 200])
|
||||
@@ -298,94 +87,7 @@ describe('ps selection compatibility', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('asynchronous PTY process discovery', () => {
|
||||
it('requires a stopped state for the exact shell process', () => {
|
||||
expect(isPosixPtyRootStopped('100 100 pts/test Ts\n101 101 pts/test R+', 100)).toBe(true)
|
||||
expect(isPosixPtyRootStopped('100 100 pts/test S\n101 101 pts/test T', 100)).toBe(false)
|
||||
expect(isPosixPtyRootStopped('101 101 pts/test T', 100)).toBe(false)
|
||||
expect(isPosixPtyRootStopped('100 100 pts/test\n101 101 pts/test T', 100)).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds each lookup and selects the root terminal without synchronous subprocesses', async () => {
|
||||
const controller = new AbortController()
|
||||
runProcessMock
|
||||
.mockResolvedValueOnce({ code: 0, stdout: '100 100 ttys001' })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: TABLE })
|
||||
|
||||
expect(await readPosixPtyProcessTable(100, controller.signal)).toBe(`100 100 ttys001\n${TABLE}`)
|
||||
expect(
|
||||
runProcessMock.mock.calls.map(([spec]) => [{ ...spec, env: { LC_ALL: spec.env.LC_ALL } }])
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
program: 'ps',
|
||||
env: { LC_ALL: 'C' },
|
||||
args: ['-p', '100', '-o', 'pid=,pgid=,tty=,stat='],
|
||||
timeoutMs: 1000,
|
||||
maxOutputBytes: 1048576,
|
||||
signal: controller.signal
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
program: 'ps',
|
||||
env: { LC_ALL: 'C' },
|
||||
args: ['-t', 'ttys001', '-o', 'pid=,pgid=,tty=,stat='],
|
||||
timeoutMs: 1000,
|
||||
maxOutputBytes: 1048576,
|
||||
signal: controller.signal
|
||||
}
|
||||
]
|
||||
])
|
||||
expect(runProcessSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([{ code: 1 }, { code: 0, timedOut: true }, { code: 0, outputTruncated: true }])(
|
||||
'rejects incomplete process evidence: %j',
|
||||
async (result) => {
|
||||
runProcessMock.mockResolvedValue({ stdout: TABLE, ...result })
|
||||
await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable')
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not start the second lookup after cancellation', async () => {
|
||||
const controller = new AbortController()
|
||||
runProcessMock.mockImplementation(async () => {
|
||||
controller.abort()
|
||||
return { code: 0, stdout: '100 100 ttys001' }
|
||||
})
|
||||
await expect(readPosixPtyProcessTable(100, controller.signal)).rejects.toThrow()
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POSIX PTY process-group termination', () => {
|
||||
it('returns every group attached to the root PTY with the root group last', () => {
|
||||
expect(getPosixPtyProcessGroups(TABLE, 100, 999)).toEqual([101, 103, 100])
|
||||
})
|
||||
|
||||
it('refuses an unbound root or a PTY shared with Orca itself', () => {
|
||||
expect(getPosixPtyProcessGroups(TABLE, 300, 999)).toBeNull()
|
||||
expect(getPosixPtyProcessGroups(TABLE, 100, 102)).toBeNull()
|
||||
expect(getPosixPtyProcessGroups(TABLE, 999, 998)).toBeNull()
|
||||
})
|
||||
|
||||
it('kills foreground and background groups before the PTY leader', async () => {
|
||||
const fallback = vi.fn()
|
||||
const signalProcessGroup = vi.fn()
|
||||
|
||||
await forceKillPosixPtyProcessGroups(100, fallback, {
|
||||
platform: 'darwin',
|
||||
currentPid: 999,
|
||||
readProcessTable: async () => TABLE,
|
||||
signalProcessGroup
|
||||
})
|
||||
|
||||
expect(signalProcessGroup.mock.calls.map(([pgid]) => pgid)).toEqual([101, 103, 100])
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['SIGSTOP', [99, 101, 103]],
|
||||
['SIGCONT', [101, 103, 99]]
|
||||
@@ -420,42 +122,6 @@ describe('POSIX PTY process-group termination', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('falls back when the process table cannot prove PTY ownership', async () => {
|
||||
const fallback = vi.fn()
|
||||
|
||||
await forceKillPosixPtyProcessGroups(100, fallback, {
|
||||
platform: 'linux',
|
||||
currentPid: 102,
|
||||
readProcessTable: async () => TABLE,
|
||||
signalProcessGroup: vi.fn()
|
||||
})
|
||||
|
||||
expect(fallback).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ignores groups that exited after the snapshot but preserves real signal errors', async () => {
|
||||
const gone = Object.assign(new Error('gone'), { code: 'ESRCH' })
|
||||
const denied = Object.assign(new Error('denied'), { code: 'EPERM' })
|
||||
const signalProcessGroup = vi
|
||||
.fn<(pgid: number) => void>()
|
||||
.mockImplementationOnce(() => {
|
||||
throw gone
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw denied
|
||||
})
|
||||
|
||||
await expect(
|
||||
forceKillPosixPtyProcessGroups(100, vi.fn(), {
|
||||
platform: 'darwin',
|
||||
currentPid: 999,
|
||||
readProcessTable: async () => TABLE,
|
||||
signalProcessGroup
|
||||
})
|
||||
).rejects.toThrow('denied')
|
||||
expect(signalProcessGroup).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('signals nothing when the root exits while the table is read', async () => {
|
||||
// A reaped root frees its pid and TTY, so the table may now describe another terminal.
|
||||
let rootExited = false
|
||||
@@ -476,19 +142,6 @@ describe('POSIX PTY process-group termination', () => {
|
||||
expect(signalProcessGroup).not.toHaveBeenCalled()
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the existing fallback on Windows without reading ps', async () => {
|
||||
const fallback = vi.fn()
|
||||
const readProcessTable = vi.fn(async () => TABLE)
|
||||
|
||||
await forceKillPosixPtyProcessGroups(100, fallback, {
|
||||
platform: 'win32',
|
||||
readProcessTable
|
||||
})
|
||||
|
||||
expect(fallback).toHaveBeenCalledOnce()
|
||||
expect(readProcessTable).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Linux terminal session identity (#27337)', () => {
|
||||
@@ -501,13 +154,6 @@ describe('Linux terminal session identity (#27337)', () => {
|
||||
600 600 pts/6 Ss
|
||||
`
|
||||
|
||||
it('parses the session id past a comm containing spaces and parentheses', () => {
|
||||
expect(parseLinuxProcStatSessionId(procStat(42, 7, 'evil) S 1 2 3 (x'))).toBe(7)
|
||||
expect(parseLinuxProcStatSessionId('42 (bash')).toBeNull()
|
||||
expect(parseLinuxProcStatSessionId('42 (bash) S 1 42')).toBeNull()
|
||||
expect(parseLinuxProcStatSessionId('42 (bash) S 1 42 0 0')).toBeNull()
|
||||
})
|
||||
|
||||
it('force-kills only groups in the shell session, not container processes on the same pts', async () => {
|
||||
procSessions.set(500, 500).set(501, 500).set(600, 600)
|
||||
const signalProcessGroup = vi.fn()
|
||||
@@ -522,85 +168,4 @@ describe('Linux terminal session identity (#27337)', () => {
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
expect(readFileMock).toHaveBeenCalledWith('/proc/500/stat', 'utf8')
|
||||
})
|
||||
|
||||
it('applies the same session filter to the stop/continue sweep', async () => {
|
||||
procSessions.set(500, 500).set(501, 500).set(600, 600)
|
||||
const signalProcessGroup = vi.fn()
|
||||
await signalPosixPtyProcessGroups(100, 'SIGSTOP', vi.fn(), {
|
||||
platform: 'linux',
|
||||
currentPid: 999,
|
||||
readProcessTable: async () => COLLIDING_TABLE,
|
||||
signalProcessGroup
|
||||
})
|
||||
expect(signalProcessGroup.mock.calls).toEqual([[100], [101]])
|
||||
})
|
||||
|
||||
it('excludes candidates whose session can no longer be read', () => {
|
||||
expect(
|
||||
getPosixPtyProcessGroups(COLLIDING_TABLE, 100, 999, (pid) =>
|
||||
pid === 101 ? null : pid < 200 ? 100 : pid
|
||||
)
|
||||
).toEqual([100])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unreadable', async () => null],
|
||||
['not a session leader', async () => 50]
|
||||
])('falls back to the root-only kill when the shell session is %s', async (_label, sessionOf) => {
|
||||
const signalProcessGroup = vi.fn()
|
||||
const fallback = vi.fn()
|
||||
await forceKillPosixPtyProcessGroups(100, fallback, {
|
||||
platform: 'linux',
|
||||
currentPid: 999,
|
||||
readProcessTable: async () => COLLIDING_TABLE,
|
||||
readProcessSessionId: sessionOf,
|
||||
signalProcessGroup
|
||||
})
|
||||
expect(fallback).toHaveBeenCalledOnce()
|
||||
expect(signalProcessGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps macOS selection by terminal alone without reading /proc', async () => {
|
||||
const signalProcessGroup = vi.fn()
|
||||
await forceKillPosixPtyProcessGroups(100, vi.fn(), {
|
||||
platform: 'darwin',
|
||||
currentPid: 999,
|
||||
readProcessTable: async () => COLLIDING_TABLE,
|
||||
signalProcessGroup
|
||||
})
|
||||
expect(signalProcessGroup.mock.calls).toEqual([[101], [500], [600], [100]])
|
||||
expect(readFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POSIX PTY group-sweep breadcrumbs', () => {
|
||||
it('records every group it actually signalled', async () => {
|
||||
await forceKillPosixPtyProcessGroups(100, vi.fn(), {
|
||||
platform: 'darwin',
|
||||
currentPid: 999,
|
||||
readProcessTable: async () => TABLE,
|
||||
signalProcessGroup: vi.fn()
|
||||
})
|
||||
|
||||
expect(recordSelfInitiatedTreeKillMock.mock.calls.map(([kill]) => kill)).toEqual([
|
||||
{ pid: 101, site: 'posix-pty-process-group-sweep', scope: 'posix-process-group' },
|
||||
{ pid: 103, site: 'posix-pty-process-group-sweep', scope: 'posix-process-group' },
|
||||
{ pid: 100, site: 'posix-pty-process-group-sweep', scope: 'posix-process-group' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not claim a group that was already gone', async () => {
|
||||
await forceKillPosixPtyProcessGroups(100, vi.fn(), {
|
||||
platform: 'darwin',
|
||||
currentPid: 999,
|
||||
readProcessTable: async () => TABLE,
|
||||
signalProcessGroup: (pgid: number) => {
|
||||
if (pgid === 103) {
|
||||
throw Object.assign(new Error('no such process'), { code: 'ESRCH' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(recordSelfInitiatedTreeKillMock.mock.calls.map(([kill]) => kill.pid)).toEqual([101, 100])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,15 +11,9 @@ import {
|
||||
killWithDescendantSweep,
|
||||
terminateDescendantSnapshot
|
||||
} from './pty-descendant-termination'
|
||||
import {
|
||||
DESCENDANT_SNAPSHOT_TIMEOUT_MS,
|
||||
type ProcessTableCapture
|
||||
} from './pty-descendant-fresh-table'
|
||||
import { parseProcessTable, type ProcessTableRow } from './pty-process-table-parser'
|
||||
import {
|
||||
terminateDescendantSnapshotAndWait,
|
||||
terminateDescendantSnapshotWithVerdict
|
||||
} from './pty-descendant-exit-verification'
|
||||
import type { ProcessTableCapture } from './pty-descendant-fresh-table'
|
||||
import type { ProcessTableRow } from './pty-process-table-parser'
|
||||
import { terminateDescendantSnapshotWithVerdict } from './pty-descendant-exit-verification'
|
||||
import { excludeShellBackgroundJobs } from './pty-shell-job-preservation'
|
||||
|
||||
const CAPTURED_AT_MS = Date.parse('Tue Jul 14 12:00:00 2026')
|
||||
@@ -67,40 +61,7 @@ function snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseProcessTable', () => {
|
||||
it('parses pid/ppid/pgid and keeps the space-containing lstart verbatim', () => {
|
||||
const rows = parseProcessTable(
|
||||
[
|
||||
' 101 1 101 -1 Mon Jul 13 12:54:47 2026',
|
||||
'42017 101 42017 42017 Tue Jul 14 01:02:03 2026 ',
|
||||
'',
|
||||
'not a process line'
|
||||
].join('\n')
|
||||
)
|
||||
expect(rows).toEqual([
|
||||
{ pid: 101, ppid: 1, pgid: 101, tpgid: -1, startedAt: 'Mon Jul 13 12:54:47 2026' },
|
||||
{ pid: 42017, ppid: 101, pgid: 42017, tpgid: 42017, startedAt: 'Tue Jul 14 01:02:03 2026' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectDescendantRows', () => {
|
||||
it('walks detached-pgid descendants once even when a non-atomic table looks cyclic', () => {
|
||||
// Detached process groups still share ancestry: 10 → 20 → 30 → 31; 99 is unrelated.
|
||||
const table = [
|
||||
row(10, 1, 10),
|
||||
row(20, 10, 20),
|
||||
row(30, 20, 30),
|
||||
row(31, 30, 30),
|
||||
row(20, 31, 20), // PID reuse can make a non-atomic ps read look cyclic.
|
||||
row(99, 1, 99)
|
||||
]
|
||||
const snapshot = collectDescendantRows(10, table, CAPTURED_AT_MS)
|
||||
expect(snapshot.rootPgid).toBe(10)
|
||||
expect(snapshot.descendants.map((r) => r.pid)).toEqual([20, 30, 31])
|
||||
expect(snapshot.capturedAtMs).toBe(CAPTURED_AT_MS)
|
||||
})
|
||||
|
||||
it("excludes only the shell's own background jobs; agent-made process groups stay swept", () => {
|
||||
const tty = (pid: number, ppid: number, pgid: number, tpgid: number) => ({
|
||||
...row(pid, ppid, pgid),
|
||||
@@ -156,36 +117,6 @@ describe('captureDescendantSnapshot', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('captures no descendants and signals nothing when the root PID was already recycled', async () => {
|
||||
const readTable = vi
|
||||
.fn()
|
||||
.mockResolvedValue(tableCapture([row(501, 500, 500), row(502, 500, 500), row(503, 500, 500)]))
|
||||
const result = await captureDescendantSnapshot(500, { readTable, platform: 'darwin' })
|
||||
expect(result?.descendants).toEqual([])
|
||||
const sendSignal = vi.fn()
|
||||
terminateDescendantSnapshot(result!, { sendSignal })
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('degrades to null when a custom process-table reader throws synchronously', async () => {
|
||||
const readTable = vi.fn(() => {
|
||||
throw new Error('reader exploded')
|
||||
})
|
||||
expect(await captureDescendantSnapshot(10, { readTable, platform: 'linux' })).toBeNull()
|
||||
})
|
||||
|
||||
it('degrades to null when ps hangs past the timeout instead of blocking teardown', async () => {
|
||||
const readTable = vi.fn().mockReturnValue(new Promise<ProcessTableCapture>(() => {}))
|
||||
const pending = captureDescendantSnapshot(10, {
|
||||
readTable,
|
||||
platform: 'darwin',
|
||||
timeoutMs: 1_000
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(await pending).toBeNull()
|
||||
})
|
||||
|
||||
it('records the identity boundary before ps starts even when it crosses a second', async () => {
|
||||
vi.setSystemTime(CAPTURED_AT_MS + 900)
|
||||
execFileMock.mockImplementation((...args: unknown[]) => {
|
||||
@@ -240,18 +171,6 @@ describe('terminateDescendantSnapshot', () => {
|
||||
expect(sendSignal.mock.calls).toEqual([[30, 'SIGKILL']])
|
||||
})
|
||||
|
||||
it('bounds a wedged escalation read and releases its deadline timer', async () => {
|
||||
const sendSignal = vi.fn()
|
||||
terminateDescendantSnapshot(snapshot([row(20, 10, 20)]), {
|
||||
sendSignal,
|
||||
readTable: vi.fn().mockReturnValue(new Promise<ProcessTableCapture>(() => {}))
|
||||
})
|
||||
sendSignal.mockClear()
|
||||
await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS + DESCENDANT_SNAPSHOT_TIMEOUT_MS)
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it("uses each row's capture boundary when escalating a merged snapshot", async () => {
|
||||
const oldBoundary = CAPTURED_AT_MS + 900
|
||||
const refreshBoundary = CAPTURED_AT_MS + 2_100
|
||||
@@ -285,77 +204,6 @@ describe('terminateDescendantSnapshotAndWait', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('escalates an identity-matched survivor and verifies its exit', async () => {
|
||||
const survivor = row(20, 10, 20)
|
||||
const sendSignal = vi.fn()
|
||||
const readTable = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(tableCapture([survivor]))
|
||||
.mockResolvedValueOnce(tableCapture([]))
|
||||
|
||||
const pending = terminateDescendantSnapshotAndWait(snapshot([survivor]), {
|
||||
sendSignal,
|
||||
readTable,
|
||||
graceMs: 0,
|
||||
verifyMs: 200
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(pending).resolves.toBe(true)
|
||||
expect(sendSignal.mock.calls).toEqual([
|
||||
[20, 'SIGTERM'],
|
||||
[20, 'SIGKILL']
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps polling past a read that missed its deadline rather than surrendering', async () => {
|
||||
const survivor = row(20, 10, 20)
|
||||
const readTable = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('ps timed out'))
|
||||
.mockResolvedValueOnce(tableCapture([survivor]))
|
||||
.mockResolvedValueOnce(tableCapture([]))
|
||||
const sendSignal = vi.fn()
|
||||
|
||||
const pending = terminateDescendantSnapshotWithVerdict(snapshot([survivor]), {
|
||||
sendSignal,
|
||||
readTable,
|
||||
graceMs: 0,
|
||||
verifyMs: 2_000
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
await expect(pending).resolves.toBe('exited')
|
||||
expect(sendSignal.mock.calls).toEqual([
|
||||
[20, 'SIGTERM'],
|
||||
[20, 'SIGKILL']
|
||||
])
|
||||
})
|
||||
|
||||
it('names a survivor seen at the deadline live, never unverifiable', async () => {
|
||||
const survivor = row(20, 10, 20)
|
||||
const pending = terminateDescendantSnapshotWithVerdict(snapshot([survivor]), {
|
||||
sendSignal: vi.fn(),
|
||||
readTable: vi.fn().mockResolvedValue(tableCapture([survivor])),
|
||||
graceMs: 0,
|
||||
verifyMs: 100
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
await expect(pending).resolves.toBe('live')
|
||||
})
|
||||
|
||||
it('names an unreadable verification table unverifiable', async () => {
|
||||
const pending = terminateDescendantSnapshotWithVerdict(snapshot([row(20, 10, 20)]), {
|
||||
sendSignal: vi.fn(),
|
||||
readTable: vi.fn().mockRejectedValue(new Error('ps exploded')),
|
||||
verifyMs: 200
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
|
||||
await expect(pending).resolves.toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('never escalates a duplicate PID observation during shutdown verification', async () => {
|
||||
const survivor = row(20, 10, 20)
|
||||
const sendSignal = vi.fn()
|
||||
@@ -373,24 +221,6 @@ describe('terminateDescendantSnapshotAndWait', () => {
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not signal a recycled descendant when identity validation is required', async () => {
|
||||
const sendSignal = vi.fn()
|
||||
const recycled = row(20, 10, 20, 'Tue Jul 14 13:00:00 2026')
|
||||
const pending = terminateDescendantSnapshotWithVerdict(
|
||||
snapshot([row(20, 10, 20, 'Tue Jul 14 12:00:00 2026')]),
|
||||
{
|
||||
sendSignal,
|
||||
readTable: vi.fn().mockResolvedValue(tableCapture([recycled], CAPTURED_AT_MS + 1_000)),
|
||||
requireIdentityBeforeSignal: true,
|
||||
verifyMs: 100
|
||||
}
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await expect(pending).resolves.toBe('exited')
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses row-scoped boundaries for forced cleanup in the exit verifier', async () => {
|
||||
const oldBoundary = CAPTURED_AT_MS + 900
|
||||
const refreshBoundary = CAPTURED_AT_MS + 2_100
|
||||
@@ -424,19 +254,6 @@ describe('terminateDescendantSnapshotAndWait', () => {
|
||||
})
|
||||
|
||||
describe('createProcessTableSnapshotReader', () => {
|
||||
it('coalesces same-turn teardown requests onto one fresh scan', async () => {
|
||||
const capture = tableCapture([row(10, 1, 10)])
|
||||
const readFresh = vi.fn().mockResolvedValue(capture)
|
||||
const readTable = createProcessTableSnapshotReader(readFresh)
|
||||
|
||||
const results = await Promise.all(Array.from({ length: 20 }, () => readTable(1_000)))
|
||||
expect(results.every((result) => result === capture)).toBe(true)
|
||||
expect(readFresh).toHaveBeenCalledOnce()
|
||||
|
||||
await readTable(1_000)
|
||||
expect(readFresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('queues a post-request scan when another scan has already started', async () => {
|
||||
const firstGate = deferred<ProcessTableCapture>()
|
||||
const secondGate = deferred<ProcessTableCapture>()
|
||||
@@ -462,17 +279,6 @@ describe('createProcessTableSnapshotReader', () => {
|
||||
secondGate.resolve(newer)
|
||||
await expect(Promise.all([laterA, laterB])).resolves.toEqual([newer, newer])
|
||||
})
|
||||
|
||||
it('retries after failed process-table reads', async () => {
|
||||
const readFresh = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('ps failed'))
|
||||
.mockResolvedValueOnce(tableCapture([]))
|
||||
const readTable = createProcessTableSnapshotReader(readFresh)
|
||||
await expect(readTable()).rejects.toThrow('ps failed')
|
||||
await expect(readTable()).resolves.toEqual(tableCapture([]))
|
||||
expect(readFresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('killWithDescendantSweep', () => {
|
||||
@@ -506,39 +312,6 @@ describe('killWithDescendantSweep', () => {
|
||||
expect(events).toEqual(['descendants', 'root', 'finished'])
|
||||
})
|
||||
|
||||
it('signals descendants after snapshot resolution, then kills the root', async () => {
|
||||
const events: string[] = []
|
||||
const sendSignal = vi.fn(() => events.push('descendant-term'))
|
||||
const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)]))
|
||||
const killRoot = vi.fn(() => events.push('root-kill'))
|
||||
const pending = killWithDescendantSweep(10, killRoot, {
|
||||
readTable,
|
||||
sendSignal,
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(killRoot).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await pending
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
expect(sendSignal.mock.calls).toEqual([[20, 'SIGTERM']])
|
||||
expect(events).toEqual(['descendant-term', 'root-kill'])
|
||||
})
|
||||
|
||||
it('still kills the root when the snapshot is unavailable', async () => {
|
||||
const sendSignal = vi.fn()
|
||||
const readTable = vi.fn().mockRejectedValue(new Error('ps exploded'))
|
||||
const killRoot = vi.fn()
|
||||
const pending = killWithDescendantSweep(10, killRoot, {
|
||||
readTable,
|
||||
sendSignal,
|
||||
platform: 'darwin'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await pending
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('on Windows terminates the owning job instead of probing and taskkilling', async () => {
|
||||
const events: string[] = []
|
||||
const terminateOwnedTree = vi.fn(() => {
|
||||
@@ -563,84 +336,6 @@ describe('killWithDescendantSweep', () => {
|
||||
expect(events).toEqual(['job-kill', 'root-kill'])
|
||||
})
|
||||
|
||||
it('on Windows falls back to the probe when the pty has no job', async () => {
|
||||
// Older or unassigned PTYs lack jobs; unavailable cannot prove their processes exited.
|
||||
const terminateOwnedTree = vi.fn(() => 'unavailable' as const)
|
||||
const killWindowsTree = vi.fn(async () => {})
|
||||
const killRoot = vi.fn()
|
||||
|
||||
await killWithDescendantSweep(4242, killRoot, {
|
||||
platform: 'win32',
|
||||
terminateOwnedTree,
|
||||
killWindowsTree,
|
||||
verifyTreeKillTarget: async () => 'own'
|
||||
})
|
||||
|
||||
expect(killWindowsTree).toHaveBeenCalledWith(4242)
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('on Windows taskkills the process tree before killRoot (#10004)', async () => {
|
||||
const events: string[] = []
|
||||
const killWindowsTree = vi.fn(async () => {
|
||||
events.push('tree-kill')
|
||||
})
|
||||
const killRoot = vi.fn(() => events.push('root-kill'))
|
||||
const sendSignal = vi.fn()
|
||||
const readTable = vi.fn()
|
||||
await killWithDescendantSweep(4242, killRoot, {
|
||||
platform: 'win32',
|
||||
killWindowsTree,
|
||||
sendSignal,
|
||||
readTable,
|
||||
// Avoid querying the host’s real process table for this synthetic PID.
|
||||
verifyTreeKillTarget: async () => 'own'
|
||||
})
|
||||
expect(killWindowsTree).toHaveBeenCalledWith(4242)
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
expect(readTable).not.toHaveBeenCalled()
|
||||
expect(events).toEqual(['tree-kill', 'root-kill'])
|
||||
})
|
||||
|
||||
it('on Windows skips taskkill when the root pid was recycled by a stranger', async () => {
|
||||
const killWindowsTree = vi.fn(async () => {})
|
||||
const killRoot = vi.fn()
|
||||
await killWithDescendantSweep(4242, killRoot, {
|
||||
platform: 'win32',
|
||||
killWindowsTree,
|
||||
verifyTreeKillTarget: async () => 'foreign'
|
||||
})
|
||||
expect(killWindowsTree).not.toHaveBeenCalled()
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('on Windows skips taskkill when identity is unknown', async () => {
|
||||
const killWindowsTree = vi.fn(async () => {})
|
||||
const killRoot = vi.fn()
|
||||
await killWithDescendantSweep(4242, killRoot, {
|
||||
platform: 'win32',
|
||||
killWindowsTree,
|
||||
verifyTreeKillTarget: async () => 'unknown'
|
||||
})
|
||||
expect(killWindowsTree).not.toHaveBeenCalled()
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('on Windows skips taskkill when the identity probe throws', async () => {
|
||||
const killWindowsTree = vi.fn(async () => {})
|
||||
const killRoot = vi.fn()
|
||||
await killWithDescendantSweep(4242, killRoot, {
|
||||
platform: 'win32',
|
||||
killWindowsTree,
|
||||
verifyTreeKillTarget: async () => {
|
||||
throw new Error('probe exploded')
|
||||
}
|
||||
})
|
||||
expect(killWindowsTree).not.toHaveBeenCalled()
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('on Windows re-checks ownership lost while the identity probe ran', async () => {
|
||||
const killWindowsTree = vi.fn(async () => {})
|
||||
const killRoot = vi.fn()
|
||||
@@ -657,50 +352,4 @@ describe('killWithDescendantSweep', () => {
|
||||
expect(killWindowsTree).not.toHaveBeenCalled()
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('on Windows does not probe identity once ownership is already lost', async () => {
|
||||
const verifyTreeKillTarget = vi.fn(async () => 'own' as const)
|
||||
const killRoot = vi.fn()
|
||||
await killWithDescendantSweep(4242, killRoot, {
|
||||
platform: 'win32',
|
||||
killWindowsTree: vi.fn(async () => {}),
|
||||
ownsRoot: () => false,
|
||||
verifyTreeKillTarget
|
||||
})
|
||||
expect(verifyTreeKillTarget).not.toHaveBeenCalled()
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('on Windows still kills the root when taskkill fails', async () => {
|
||||
const killWindowsTree = vi.fn(async () => {
|
||||
throw new Error('taskkill failed')
|
||||
})
|
||||
const killRoot = vi.fn()
|
||||
await expect(
|
||||
killWithDescendantSweep(99, killRoot, {
|
||||
platform: 'win32',
|
||||
killWindowsTree,
|
||||
verifyTreeKillTarget: async () => 'own'
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not signal a captured tree after the caller loses root ownership', async () => {
|
||||
const sendSignal = vi.fn()
|
||||
const killRoot = vi.fn()
|
||||
const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)]))
|
||||
const ownsRoot = vi.fn(() => false)
|
||||
|
||||
await killWithDescendantSweep(10, killRoot, {
|
||||
readTable,
|
||||
sendSignal,
|
||||
platform: 'darwin',
|
||||
ownsRoot
|
||||
})
|
||||
|
||||
expect(ownsRoot).toHaveBeenCalledOnce()
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import type * as ForegroundModule from './terminal-process-rows'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
observePtyForeground,
|
||||
readPtyForegroundGroup,
|
||||
resolvePtyForegroundMembers
|
||||
} from './pty-master-foreground-observation'
|
||||
import { isTerminalForegroundObservation } from '../../../shared/terminal/pty/lifecycle/terminal-foreground-observation'
|
||||
import { judgeTerminalForeground } from '../../runtime/terminal-foreground-group'
|
||||
import type { ProcessTableRow } from '../../../shared/process-table-snapshot'
|
||||
import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent'
|
||||
import { observePtyForeground, readPtyForegroundGroup } from './pty-master-foreground-observation'
|
||||
const mocks = vi.hoisted(() => ({ rows: vi.fn(), tty: vi.fn(), ps: vi.fn() }))
|
||||
vi.mock('./terminal-process-rows', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ForegroundModule>()),
|
||||
@@ -37,54 +29,6 @@ beforeEach(() => {
|
||||
mocks.ps.mockResolvedValue('200 pts/5')
|
||||
})
|
||||
describe('physical PTY observations', () => {
|
||||
it('survives client rebind and a missing group leader/root row', async () => {
|
||||
const owner = proc()
|
||||
const first = await observePtyForeground(owner, () => true)
|
||||
const afterRebind = await observePtyForeground(owner, () => true)
|
||||
expect(first).toMatchObject({
|
||||
verdict: 'observed',
|
||||
source: 'pty-master',
|
||||
foregroundKind: 'non-shell',
|
||||
processName: 'claude',
|
||||
foregroundPgid: 200
|
||||
})
|
||||
expect(afterRebind).toEqual(first)
|
||||
expect(mocks.tty).not.toHaveBeenCalled()
|
||||
})
|
||||
it('uses all members rather than naming only a wrapper leader', async () => {
|
||||
mocks.rows.mockResolvedValue([
|
||||
row('sh ./wrapper', 200),
|
||||
{ ...row('node /opt/node_modules/@openai/codex/bin/codex.js'), ppid: 200 }
|
||||
])
|
||||
expect(await observePtyForeground(proc(), () => true)).toMatchObject({
|
||||
verdict: 'observed',
|
||||
foregroundKind: 'non-shell',
|
||||
processName: 'codex'
|
||||
})
|
||||
})
|
||||
it.each([
|
||||
['node /usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js --resume abc', 'claude'],
|
||||
['/home/user/.local/share/claude/versions/2.1.26', '2.1.26'],
|
||||
['python3 /opt/work/unknown_agent.py', 'python3']
|
||||
])(
|
||||
'resolves real installation argv without rewriting the user command: %s',
|
||||
(command, processName) => {
|
||||
const sample = resolvePtyForegroundMembers([row(command)], 200)
|
||||
expect(sample).toMatchObject({
|
||||
verdict: 'observed',
|
||||
foregroundKind: 'non-shell',
|
||||
processName
|
||||
})
|
||||
}
|
||||
)
|
||||
it('keeps unreadable non-shell identity separate from local non-shell admission', () => {
|
||||
expect(resolvePtyForegroundMembers([row('(node)')], 200)).toMatchObject({
|
||||
verdict: 'observed',
|
||||
foregroundKind: 'non-shell',
|
||||
processName: null
|
||||
})
|
||||
})
|
||||
|
||||
it('never queries a retired/reused fd or rescues a supported failure with a cached name', async () => {
|
||||
const owner = proc()
|
||||
owner.fd = -1
|
||||
@@ -109,21 +53,7 @@ describe('physical PTY observations', () => {
|
||||
reason: 'session_changed'
|
||||
})
|
||||
})
|
||||
it('rereads at most once when the group changes and obeys its original deadline', async () => {
|
||||
const owner = proc()
|
||||
owner.getForegroundProcessGroup
|
||||
.mockReturnValueOnce(200)
|
||||
.mockReturnValueOnce(300)
|
||||
.mockReturnValueOnce(200)
|
||||
.mockReturnValueOnce(300)
|
||||
expect(await observePtyForeground(owner, () => true)).toMatchObject({ verdict: 'unverifiable' })
|
||||
expect(mocks.rows).toHaveBeenCalledTimes(2)
|
||||
mocks.rows.mockClear()
|
||||
expect(await observePtyForeground(proc(), () => true, 0)).toMatchObject({
|
||||
verdict: 'unverifiable'
|
||||
})
|
||||
expect(mocks.rows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supports older native images with fresh PTY-matched root proof', async () => {
|
||||
const owner = proc()
|
||||
owner.getForegroundProcessGroup.mockReturnValue(undefined)
|
||||
@@ -136,166 +66,4 @@ describe('physical PTY observations', () => {
|
||||
mocks.tty.mockResolvedValue('pts/6')
|
||||
expect(await observePtyForeground(owner, () => true)).toMatchObject({ verdict: 'unverifiable' })
|
||||
})
|
||||
it('validates optional host observations without treating a future/malformed branch as proof', () => {
|
||||
const metadata = {
|
||||
ptyId: 'p',
|
||||
ptyIncarnationId: 'i',
|
||||
authorityGeneration: 'a',
|
||||
observationEpoch: 1,
|
||||
capturedAgeMs: 0
|
||||
}
|
||||
expect(
|
||||
isTerminalForegroundObservation({
|
||||
...metadata,
|
||||
verdict: 'observed',
|
||||
source: 'pty-master',
|
||||
foregroundKind: 'non-shell',
|
||||
processName: 'claude',
|
||||
foregroundPgid: 200
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isTerminalForegroundObservation({
|
||||
...metadata,
|
||||
verdict: 'observed',
|
||||
source: 'pty-master',
|
||||
foregroundKind: 'agent',
|
||||
processName: 'claude'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isTerminalForegroundObservation({
|
||||
...metadata,
|
||||
verdict: 'unverifiable',
|
||||
reason: 'new_host_reason'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/** `ps -t` rows on one terminal: the shell (root 100) and whatever holds foreground group `tpgid`. */
|
||||
function terminal(
|
||||
tpgid: number,
|
||||
members: [pid: number, ppid: number, pgid: number, stat: string, command: string][]
|
||||
): ProcessTableRow[] {
|
||||
return members.map(([pid, ppid, pgid, stat, command]) => ({
|
||||
pid,
|
||||
ppid,
|
||||
pgid,
|
||||
tpgid,
|
||||
stat,
|
||||
command
|
||||
}))
|
||||
}
|
||||
const CLAUDE_NODE = 'node /usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js'
|
||||
const CODEX_NODE = 'node /usr/local/lib/node_modules/@openai/codex/bin/codex.js'
|
||||
const CODEX_NATIVE =
|
||||
'/usr/local/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex/codex'
|
||||
|
||||
describe('local admission parity with the root-pid judge', () => {
|
||||
it.each<[string, TuiAgent, ProcessTableRow[]]>([
|
||||
['plain shell', 'claude', terminal(100, [[100, 1, 100, 'Ss+', '-zsh']])],
|
||||
[
|
||||
'nested shell',
|
||||
'claude',
|
||||
terminal(200, [
|
||||
[100, 1, 100, 'Ss', '-zsh'],
|
||||
[200, 100, 200, 'S+', 'bash']
|
||||
])
|
||||
],
|
||||
[
|
||||
'native claude',
|
||||
'claude',
|
||||
terminal(200, [
|
||||
[100, 1, 100, 'Ss', '-zsh'],
|
||||
[200, 100, 200, 'S+', 'claude --resume abc']
|
||||
])
|
||||
],
|
||||
[
|
||||
'node claude',
|
||||
'claude',
|
||||
terminal(200, [
|
||||
[100, 1, 100, 'Ss', '-zsh'],
|
||||
[200, 100, 200, 'S+', CLAUDE_NODE]
|
||||
])
|
||||
],
|
||||
[
|
||||
'codex node with its native child',
|
||||
'codex',
|
||||
terminal(200, [
|
||||
[100, 1, 100, 'Ss', '-zsh'],
|
||||
[200, 100, 200, 'S+', CODEX_NODE],
|
||||
[201, 200, 200, 'S+', CODEX_NATIVE]
|
||||
])
|
||||
],
|
||||
[
|
||||
'non-exec wrapper script',
|
||||
'claude',
|
||||
terminal(200, [
|
||||
[100, 1, 100, 'Ss', '-zsh'],
|
||||
[200, 100, 200, 'S+', 'sh /usr/local/bin/claude'],
|
||||
[201, 200, 200, 'S+', CLAUDE_NODE]
|
||||
])
|
||||
],
|
||||
[
|
||||
'interpreter running an agent script',
|
||||
'claude',
|
||||
terminal(200, [
|
||||
[100, 1, 100, 'Ss', '-zsh'],
|
||||
[200, 100, 200, 'S+', 'bash /opt/bin/claude']
|
||||
])
|
||||
],
|
||||
[
|
||||
'agent stopped with Ctrl-Z',
|
||||
'claude',
|
||||
terminal(100, [
|
||||
[100, 1, 100, 'Ss+', '-zsh'],
|
||||
[200, 100, 200, 'T', 'claude']
|
||||
])
|
||||
],
|
||||
[
|
||||
'tmux client',
|
||||
'claude',
|
||||
terminal(200, [
|
||||
[100, 1, 100, 'Ss', '-zsh'],
|
||||
[200, 100, 200, 'S+', 'tmux attach']
|
||||
])
|
||||
],
|
||||
[
|
||||
'sudo with withheld argv',
|
||||
'claude',
|
||||
terminal(200, [
|
||||
[100, 1, 100, 'Ss', '-zsh'],
|
||||
[200, 100, 200, 'S+', '(sudo)']
|
||||
])
|
||||
],
|
||||
[
|
||||
'unreadable root shell',
|
||||
'claude',
|
||||
terminal(200, [
|
||||
[100, 1, 100, 'Ss', '-zsh'],
|
||||
[200, 100, 200, 'S+', '(bash)']
|
||||
])
|
||||
]
|
||||
])('%s', (_name, agent, rows) => {
|
||||
const sample = resolvePtyForegroundMembers(rows, rows[0].tpgid ?? 0)
|
||||
const local =
|
||||
sample.verdict !== 'observed'
|
||||
? 'unknown'
|
||||
: sample.foregroundKind === 'shell'
|
||||
? 'shell'
|
||||
: 'agent'
|
||||
expect(local).toBe(judgeTerminalForeground(rows, 100, agent))
|
||||
})
|
||||
|
||||
it('refuses a zombie or stopped-only foreground the root-pid judge admitted', () => {
|
||||
for (const stat of ['Z+', 'T+']) {
|
||||
const rows = terminal(200, [
|
||||
[100, 1, 100, 'Ss', '-zsh'],
|
||||
[200, 100, 200, stat, 'claude']
|
||||
])
|
||||
expect(judgeTerminalForeground(rows, 100, 'claude')).toBe('agent')
|
||||
expect(resolvePtyForegroundMembers(rows, 200).verdict).toBe('unverifiable')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,17 +13,12 @@ import { tmpdir } from 'node:os'
|
||||
import { delimiter, isAbsolute, join } from 'node:path'
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveCodexShellLaunchPreflightCommand } from './codex-shell-launch-preflight'
|
||||
import {
|
||||
CODEX_COMMAND_IDENTITY_ENV_KEYS,
|
||||
getFishCodexShellLaunchPreflight,
|
||||
getPosixCodexShellLaunchPreflight,
|
||||
getPowerShellCodexShellLaunchPreflight
|
||||
getPosixCodexShellLaunchPreflight
|
||||
} from '../../../shared/codex-shell-function'
|
||||
import {
|
||||
fishRequirementViolation,
|
||||
resolveFishBinary
|
||||
} from '../../../shared/fish-binary-requirement'
|
||||
import { resolveFishBinary } from '../../../shared/fish-binary-requirement'
|
||||
import { getPowerShellAgentWrappers } from '../../../shared/powershell-agent-wrappers'
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -199,13 +194,6 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
|
||||
expect(runAliasLaunch(afterRoot, getPosixCodexShellLaunchPreflight())).toBe('normal')
|
||||
})
|
||||
|
||||
it.skipIf(!existsSync('/bin/zsh'))('repairs a zsh cx alias before Codex starts', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-zsh-alias-'))
|
||||
roots.push(root)
|
||||
|
||||
expect(runAliasLaunch(root, getPosixCodexShellLaunchPreflight(), '/bin/zsh')).toBe('normal')
|
||||
})
|
||||
|
||||
it.skipIf(!zshAvailable)('keeps a user alias named codex working in zsh', () => {
|
||||
expectNamedAliasSurvives('/bin/zsh', 'setopt aliases')
|
||||
})
|
||||
@@ -214,39 +202,6 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
|
||||
expectNamedAliasSurvives('/bin/bash', 'shopt -s expand_aliases')
|
||||
})
|
||||
|
||||
it('still launches Codex when the best-effort preflight fails', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-failure-'))
|
||||
roots.push(root)
|
||||
|
||||
expect(runAliasLaunch(root, getPosixCodexShellLaunchPreflight(), '/bin/bash', false)).toBe(
|
||||
'hooks-review'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not trigger a preflight outside an Orca terminal', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-plain-shell-'))
|
||||
roots.push(root)
|
||||
const bin = join(root, 'bin')
|
||||
const marker = join(root, 'preflight-ran')
|
||||
mkdirSync(bin)
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n')
|
||||
writeExecutable(join(bin, 'orca-test'), `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`)
|
||||
|
||||
const output = execFileSync(
|
||||
'/bin/bash',
|
||||
['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(output.trim()).toBe('launched')
|
||||
expect(existsSync(marker)).toBe(false)
|
||||
})
|
||||
|
||||
// skipIf exposes missing shells; an early return would report an unexercised test as passing.
|
||||
for (const [shell, strict] of [
|
||||
['/bin/bash', 'set -e'],
|
||||
@@ -276,29 +231,7 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
|
||||
)
|
||||
}
|
||||
|
||||
it.skipIf(!fishAvailable)('preserves a user-defined fish function', () => {
|
||||
const output = execFileSync(
|
||||
'fish',
|
||||
[
|
||||
'--no-config',
|
||||
'-c',
|
||||
[
|
||||
'set -gx ORCA_CODEX_LAUNCH_PREFLIGHT missing-preflight',
|
||||
'function codex; echo custom-codex; end',
|
||||
getFishCodexShellLaunchPreflight(),
|
||||
'codex'
|
||||
].join('\n')
|
||||
],
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
|
||||
expect(output.trim()).toBe('custom-codex')
|
||||
})
|
||||
|
||||
// Fail explicitly when CI requires fish; skipped regression cases cannot establish coverage.
|
||||
it('has fish when CI demanded it', () => {
|
||||
expect(fishRequirementViolation(fishLookup)).toBeNull()
|
||||
})
|
||||
|
||||
// #16893: absent Codex made unquoted (type -t codex) vanish and test complain; exercise real -l -C startup.
|
||||
it.skipIf(!fishAvailable)('stays silent and installs no wrapper when codex is absent', () => {
|
||||
@@ -314,174 +247,6 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout.trim()).toBe('wrapper=NO var=[]')
|
||||
})
|
||||
|
||||
it.skipIf(!fishAvailable)('wraps codex and runs the preflight when codex is a real file', () => {
|
||||
const { bin, preflight, marker } = createFishSandbox('orca-codex-fish-present-')
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf "real codex $*"\n')
|
||||
|
||||
const output = execFileSync(
|
||||
join(bin, 'fish'),
|
||||
['--no-config', '-l', '-C', getFishCodexShellLaunchPreflight(), '-c', 'codex hi'],
|
||||
{ encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } }
|
||||
)
|
||||
|
||||
expect(output.trim()).toBe('real codex hi')
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(!fishAvailable)('leaves a codex alias unwrapped', () => {
|
||||
const { bin, preflight } = createFishSandbox('orca-codex-fish-alias-')
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf "real codex"\n')
|
||||
|
||||
const result = spawnSync(
|
||||
join(bin, 'fish'),
|
||||
[
|
||||
'--no-config',
|
||||
'-l',
|
||||
'-C',
|
||||
`alias codex='echo aliased'\n${getFishCodexShellLaunchPreflight()}`,
|
||||
'-c',
|
||||
FISH_STATE_PROBE
|
||||
],
|
||||
{ encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } }
|
||||
)
|
||||
|
||||
expect(result.stderr).not.toContain('Missing argument')
|
||||
expect(result.stdout.trim()).toBe('wrapper=NO var=[]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PowerShell Codex shell launch preflight', () => {
|
||||
it('preserves a user-defined command', () => {
|
||||
expect(getPowerShellCodexShellLaunchPreflight()).toContain(
|
||||
'$orcaCodexCommand.CommandType -in @("Application", "ExternalScript")'
|
||||
)
|
||||
})
|
||||
|
||||
it.skipIf(!pwshAvailable)('fails open when native errors are promoted', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-pwsh-failure-'))
|
||||
const bin = join(root, 'bin')
|
||||
roots.push(root)
|
||||
mkdirSync(bin)
|
||||
const executableSuffix = process.platform === 'win32' ? '.cmd' : ''
|
||||
writeExecutable(
|
||||
join(bin, `orca-test${executableSuffix}`),
|
||||
process.platform === 'win32' ? '@exit /b 7\r\n' : '#!/bin/sh\nexit 7\n'
|
||||
)
|
||||
writeExecutable(
|
||||
join(bin, `codex${executableSuffix}`),
|
||||
process.platform === 'win32' ? '@echo launched\r\n' : '#!/bin/sh\nprintf "launched\\n"\n'
|
||||
)
|
||||
|
||||
const result = spawnSync(
|
||||
'pwsh',
|
||||
[
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
[
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
'$PSNativeCommandUseErrorActionPreference = $true',
|
||||
getPowerShellAgentWrappers(),
|
||||
'codex'
|
||||
].join('\n')
|
||||
],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: join(bin, `orca-test${executableSuffix}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout.trim()).toBe('launched')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex shell launch preflight command', () => {
|
||||
function makeCliRoot(): { resourcesPath: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-cli-'))
|
||||
roots.push(root)
|
||||
const resourcesPath = join(root, 'resources')
|
||||
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
|
||||
return { resourcesPath }
|
||||
}
|
||||
|
||||
function wslOptions(resourcesPath: string | null) {
|
||||
return {
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
isWsl: true,
|
||||
managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
resourcesPath,
|
||||
platform: 'win32' as const
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ platform: 'darwin' as const, bundled: 'orca', isPackaged: true },
|
||||
{ platform: 'linux' as const, bundled: 'orca-ide', isPackaged: true },
|
||||
{ platform: 'win32' as const, bundled: 'orca.exe', isPackaged: true },
|
||||
{ platform: 'darwin' as const, bundled: 'orca', isPackaged: false }
|
||||
])(
|
||||
'gives a native $platform pane no preflight: the app prepares its Codex home (packaged $isPackaged)',
|
||||
(config) => {
|
||||
const { resourcesPath } = makeCliRoot()
|
||||
writeExecutable(join(resourcesPath, 'bin', config.bundled), '#!/bin/sh\nexit 0\n')
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: config.isPackaged,
|
||||
managedHomePath: '/managed/home',
|
||||
resourcesPath,
|
||||
platform: config.platform
|
||||
})
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('carries the packaged Windows launcher, as an absolute path, for WSLENV path translation', () => {
|
||||
const { resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
|
||||
writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
|
||||
const command = resolveCodexShellLaunchPreflightCommand(wslOptions(resourcesPath))
|
||||
|
||||
expect(command).toBe(launcherPath)
|
||||
expect(isAbsolute(command as string)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'the launcher file is missing', create: null },
|
||||
{ label: 'the launcher path is a directory', create: 'directory' as const }
|
||||
])('skips the preflight when $label', (config) => {
|
||||
const { resourcesPath } = makeCliRoot()
|
||||
if (config.create === 'directory') {
|
||||
mkdirSync(join(resourcesPath, 'bin', 'orca.exe'))
|
||||
}
|
||||
|
||||
expect(resolveCodexShellLaunchPreflightCommand(wslOptions(resourcesPath))).toBeNull()
|
||||
})
|
||||
|
||||
it('skips the preflight when the packaged build exposes no resources root', () => {
|
||||
expect(resolveCodexShellLaunchPreflightCommand(wslOptions(null))).toBeNull()
|
||||
})
|
||||
|
||||
it.each([{ hooksEnabled: false }, { isPackaged: false }, { managedHomePath: null }])(
|
||||
'does not enable an unsupported WSL preflight for %o',
|
||||
(override) => {
|
||||
const { resourcesPath } = makeCliRoot()
|
||||
writeExecutable(join(resourcesPath, 'bin', 'orca.exe'), '#!/bin/sh\nexit 0\n')
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({ ...wslOptions(resourcesPath), ...override })
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Verified launcher paths may contain spaces in app bundles or Program Files.
|
||||
|
||||
@@ -293,24 +293,6 @@ describe('codex wrapper --no-daemon rule', () => {
|
||||
expect(lines(result.stdout)).toEqual(ADDED.map((argv) => expectedLine(argv, false)))
|
||||
})
|
||||
|
||||
it('adds nothing with ORCA_CODEX_ISOLATE=0, read on every call', () => {
|
||||
const sandbox = makeSandbox(HELP_WITH_FLAG)
|
||||
const setIsolate = (value: string): string =>
|
||||
isPowerShell(shell)
|
||||
? `$env:ORCA_CODEX_ISOLATE = '${value}'`
|
||||
: shell === 'fish'
|
||||
? `set -gx ORCA_CODEX_ISOLATE ${value}`
|
||||
: `export ORCA_CODEX_ISOLATE=${value}`
|
||||
const result = run(
|
||||
shell,
|
||||
['codex a', setIsolate('1'), 'codex b', setIsolate('0'), 'codex c'].join('\n'),
|
||||
sandbox,
|
||||
{ ORCA_CODEX_ISOLATE: '0' }
|
||||
)
|
||||
|
||||
expect(lines(result.stdout)).toEqual(['ARGV|a', 'ARGV|--no-daemon|b', 'ARGV|c'])
|
||||
})
|
||||
|
||||
it('re-probes --help when Codex changes version mid-shell', () => {
|
||||
const sandbox = makeSandbox(HELP_WITH_FLAG)
|
||||
const swap = (help: string): string =>
|
||||
@@ -395,15 +377,6 @@ describe('codex wrapper --no-daemon rule', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(!available)(`${shell} forwards identity to Codex 0.155 and older too`, () => {
|
||||
const sandbox = makeSandbox(HELP_WITHOUT_FLAG)
|
||||
const result = run(shell, codexCall(shell, ['x']), sandbox, identity)
|
||||
|
||||
expect(lines(result.stdout).map(splitOverrides)).toEqual([
|
||||
{ overrides: identityOverrides, rest: ['x'] }
|
||||
])
|
||||
})
|
||||
|
||||
it.skipIf(!available)(`${shell} skips a value holding an apostrophe`, () => {
|
||||
const sandbox = makeSandbox(HELP_WITHOUT_FLAG)
|
||||
const result = run(shell, codexCall(shell, ['x']), sandbox, {
|
||||
@@ -415,52 +388,9 @@ describe('codex wrapper --no-daemon rule', () => {
|
||||
{ overrides: [identityOverrides[0]], rest: ['x'] }
|
||||
])
|
||||
})
|
||||
|
||||
it.skipIf(!available)(`${shell} forwards nothing with ORCA_CODEX_ISOLATE=0`, () => {
|
||||
const sandbox = makeSandbox(HELP_WITH_FLAG)
|
||||
const result = run(shell, codexCall(shell, ['x']), sandbox, {
|
||||
...identity,
|
||||
ORCA_CODEX_ISOLATE: '0'
|
||||
})
|
||||
|
||||
expect(lines(result.stdout)).toEqual(['ARGV|x'])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
for (const [shell, enableAliases] of [
|
||||
['bash', 'shopt -s expand_aliases'],
|
||||
['zsh', 'setopt aliases']
|
||||
] as const) {
|
||||
it.skipIf(isWindows || !existsSync(`/bin/${shell}`))(
|
||||
`applies a user alias named codex defined before the wrapper in ${shell}`,
|
||||
() => {
|
||||
const sandbox = makeSandbox(HELP_WITH_FLAG)
|
||||
const result = run(
|
||||
shell,
|
||||
'codex x',
|
||||
sandbox,
|
||||
{},
|
||||
`${enableAliases}\nalias codex='codex --alias-flag'`
|
||||
)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout.trim()).toBe('ARGV|--no-daemon|--alias-flag|x')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
it.skipIf(isWindows || !existsSync('/bin/zsh'))(
|
||||
'creates no globals under warn_create_global',
|
||||
() => {
|
||||
const sandbox = makeSandbox(HELP_WITH_FLAG)
|
||||
const result = run('zsh', 'setopt warn_create_global no_unset\ncodex x', sandbox)
|
||||
|
||||
expect(result.stderr).toBe('')
|
||||
expect(result.stdout.trim()).toBe('ARGV|--no-daemon|x')
|
||||
}
|
||||
)
|
||||
|
||||
for (const [shell, available] of shells.filter(([name]) => isPowerShell(name))) {
|
||||
it.skipIf(!available)(
|
||||
`${shell} runs under StrictMode and Stop with a failing, noisy hook prep`,
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getGitBashCandidatePaths,
|
||||
isGitForWindowsBashLauncherPath,
|
||||
isWindowsGitBashShellPath,
|
||||
resolveGitBashPath,
|
||||
resolveWindowsGitBashShellPath
|
||||
} from './git-bash'
|
||||
|
||||
describe('Git Bash path discovery', () => {
|
||||
it('includes common Git for Windows install locations and PATH-derived fallbacks', () => {
|
||||
const candidates = getGitBashCandidatePaths({
|
||||
ProgramFiles: 'C:\\Program Files',
|
||||
LOCALAPPDATA: 'C:\\Users\\alice\\AppData\\Local',
|
||||
Path: '"C:\\Program Files\\Git\\cmd";C:\\tools;C:\\PortableGit\\bin'
|
||||
})
|
||||
|
||||
expect(candidates).toContain('C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
expect(candidates).toContain('C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe')
|
||||
expect(candidates).toContain('C:\\Program Files\\Git\\usr\\bin\\bash.exe')
|
||||
expect(candidates).toContain('C:\\PortableGit\\bin\\bash.exe')
|
||||
expect(candidates).not.toContain('C:\\tools\\bash.exe')
|
||||
})
|
||||
|
||||
it('resolves the first existing common bash.exe path on Windows', () => {
|
||||
const exists = vi.fn((path: string) => path === 'C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
|
||||
expect(
|
||||
resolveGitBashPath({
|
||||
platform: 'win32',
|
||||
env: { ProgramFiles: 'C:\\Program Files' },
|
||||
exists
|
||||
})
|
||||
).toBe('C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
it('does not expose Git Bash discovery on non-Windows hosts', () => {
|
||||
expect(
|
||||
resolveGitBashPath({
|
||||
platform: 'darwin',
|
||||
env: { ProgramFiles: 'C:\\Program Files' },
|
||||
exists: () => true
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('maps the persisted Git Bash sentinel to a discovered bash.exe path', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('git-bash', {
|
||||
platform: 'win32',
|
||||
env: { LOCALAPPDATA: 'C:\\Users\\alice\\AppData\\Local' },
|
||||
exists: (path) => path === 'C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe'
|
||||
})
|
||||
).toBe('C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
// Inject platform options so these assertions also exercise Windows behavior on other hosts.
|
||||
it('honors an explicit bash.exe path for future user-configurable launch paths', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: (path) => path === 'D:\\PortableGit\\bin\\bash.exe'
|
||||
})
|
||||
).toBe('D:\\PortableGit\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
it('rejects an explicit Git Bash path that is no longer installed', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: () => false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves a bare bash entry through Git Bash discovery', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('bash', {
|
||||
platform: 'win32',
|
||||
env: { ProgramFiles: 'C:\\Program Files' },
|
||||
exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe'
|
||||
})
|
||||
).toBe('C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
it('returns null for a bare bash entry when Git Bash is not installed', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('bash', {
|
||||
platform: 'win32',
|
||||
env: { ProgramFiles: 'C:\\Program Files' },
|
||||
exists: () => false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves an extension-less Git Bash path to the bash.exe it names', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe'
|
||||
})
|
||||
).toBe('C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
it('returns null for an extension-less Git Bash path with no bash.exe beside it', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: () => false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat an extension-less non-Git bash path as Git Bash', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: () => true
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not probe a bash-prefixed path that is not bash itself', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash.old', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: () => true
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('recognizes Git Bash executable paths case-insensitively', () => {
|
||||
expect(isWindowsGitBashShellPath('D:\\PortableGit\\BIN\\BASH.EXE')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not classify arbitrary bash.exe paths as Git Bash', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('C:\\msys64\\usr\\bin\\bash.exe', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: () => true
|
||||
})
|
||||
).toBeNull()
|
||||
expect(isWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash.exe')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores non-Git bash.exe candidates discovered through PATH', () => {
|
||||
expect(
|
||||
resolveGitBashPath({
|
||||
platform: 'win32',
|
||||
env: { Path: 'C:\\msys64\\usr\\bin' },
|
||||
exists: (path) => path === 'C:\\msys64\\usr\\bin\\bash.exe'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('identifies only the bin\\bash.exe launcher, not the MSYS bash it runs', () => {
|
||||
expect(isGitForWindowsBashLauncherPath('C:\\Program Files\\Git\\bin\\bash.exe')).toBe(true)
|
||||
expect(isGitForWindowsBashLauncherPath('D:\\PortableGit\\bin\\bash.exe')).toBe(true)
|
||||
expect(isGitForWindowsBashLauncherPath('C:\\Program Files\\Git\\usr\\bin\\bash.exe')).toBe(
|
||||
false
|
||||
)
|
||||
expect(isGitForWindowsBashLauncherPath('bash.exe')).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies a launcher in an install folder named anything else', () => {
|
||||
const renamedInstall = (path: string): boolean =>
|
||||
[
|
||||
'C:\\Tools\\Git-2.55\\bin\\bash.exe',
|
||||
'C:\\Tools\\Git-2.55\\usr\\bin\\bash.exe',
|
||||
'C:\\Tools\\Git-2.55\\cmd\\git.exe',
|
||||
'C:\\Tools\\Git-2.55\\git-bash.exe'
|
||||
].includes(path)
|
||||
expect(
|
||||
isGitForWindowsBashLauncherPath('C:\\Tools\\Git-2.55\\bin\\bash.exe', {
|
||||
exists: renamedInstall
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isGitForWindowsBashLauncherPath('C:\\Users\\a\\scoop\\apps\\git\\current\\bin\\bash.exe', {
|
||||
exists: (path) => path.startsWith('C:\\Users\\a\\scoop\\apps\\git\\current\\')
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['usr\\bin\\bash.exe', 'C:\\Tools\\Git-2.55\\usr\\bin\\bash.exe'],
|
||||
['cmd\\git.exe', 'C:\\Tools\\Git-2.55\\cmd\\git.exe'],
|
||||
['git-bash.exe', 'C:\\Tools\\Git-2.55\\git-bash.exe']
|
||||
])('refuses a renamed install root missing only %s', (_label, missingMarker) => {
|
||||
expect(
|
||||
isGitForWindowsBashLauncherPath('C:\\Tools\\Git-2.55\\bin\\bash.exe', {
|
||||
exists: (path) => path !== missingMarker
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a bash that is not a Git for Windows launcher, even where every path exists', () => {
|
||||
const everythingExists = (): boolean => true
|
||||
// Direct usr/bin/bash.exe has no nested Git install root.
|
||||
expect(
|
||||
isGitForWindowsBashLauncherPath('C:\\Program Files\\Git\\usr\\bin\\bash.exe', {
|
||||
exists: (path) => !path.includes('\\usr\\usr\\') && !path.includes('\\usr\\cmd\\')
|
||||
})
|
||||
).toBe(false)
|
||||
const observedCygwinRoot = ['C:\\cygwin64\\etc', 'C:\\cygwin64\\var']
|
||||
expect(
|
||||
isGitForWindowsBashLauncherPath('C:\\cygwin64\\bin\\bash.exe', {
|
||||
exists: (path) => observedCygwinRoot.includes(path)
|
||||
})
|
||||
).toBe(false)
|
||||
// Even Git’s cmd/git.exe and usr/bin/bash.exe paths do not replace the git-bash.exe marker.
|
||||
expect(
|
||||
isGitForWindowsBashLauncherPath('C:\\cygwin64\\bin\\bash.exe', {
|
||||
exists: (path) => path.endsWith('bin\\bash.exe') || path.endsWith('cmd\\git.exe')
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isGitForWindowsBashLauncherPath('C:\\Tools\\sh\\bash.exe', { exists: everythingExists })
|
||||
).toBe(false)
|
||||
expect(isGitForWindowsBashLauncherPath('bash.exe', { exists: everythingExists })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +1,15 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
symlinkSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { symlinkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as PosixTombstoneModule from './legacy-terminal-posix-tombstone'
|
||||
import {
|
||||
readVerifiedShebangInterpreter,
|
||||
resolvePosixTombstoneInterpreter
|
||||
} from './legacy-terminal-posix-tombstone'
|
||||
import {
|
||||
__resetLegacyTerminalShimNeutralizationForTests,
|
||||
isLegacyTerminalShimPathEntry,
|
||||
neutralizeLegacyTerminalShimDir,
|
||||
stripLegacyTerminalShimEnv
|
||||
neutralizeLegacyTerminalShimDir
|
||||
} from './legacy-terminal-shim-dir'
|
||||
|
||||
const itOnPosix = process.platform === 'win32' ? it.skip : it
|
||||
// Windows ignores these permissions and root bypasses them.
|
||||
const itOnPosixNonRoot = process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it
|
||||
|
||||
// indexOf returns -1 for missing text; assert presence before ordering.
|
||||
function expectOrdered(text: string, first: string, second: string): void {
|
||||
expect(text, `missing: ${first}`).toContain(first)
|
||||
expect(text, `missing: ${second}`).toContain(second)
|
||||
expect(text.indexOf(first)).toBeLessThan(text.indexOf(second))
|
||||
}
|
||||
void (process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it)
|
||||
|
||||
describe('legacy terminal shim neutralization', () => {
|
||||
const tempRoots: string[] = []
|
||||
@@ -56,73 +31,6 @@ describe('legacy terminal shim neutralization', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('atomically replaces the legacy command paths with executable tombstones', () => {
|
||||
const userData = makeUserDataDir()
|
||||
const legacyRoot = join(userData, 'orca-terminal-attribution')
|
||||
const posixDir = join(legacyRoot, 'posix')
|
||||
const win32Dir = join(legacyRoot, 'win32')
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
|
||||
writeFileSync(join(win32Dir, 'gh.cmd'), 'legacy attribution wrapper')
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
for (const path of [
|
||||
join(posixDir, 'git'),
|
||||
join(posixDir, 'gh'),
|
||||
join(win32Dir, 'git.cmd'),
|
||||
join(win32Dir, 'gh.cmd')
|
||||
]) {
|
||||
expect(existsSync(path)).toBe(true)
|
||||
expect(readFileSync(path, 'utf8')).not.toContain('Co-authored-by: Orca')
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(path).mode & 0o111).not.toBe(0)
|
||||
}
|
||||
}
|
||||
// Matching retired version 7 would make rollback mistake tombstones for active wrappers.
|
||||
const version = readFileSync(join(legacyRoot, 'VERSION'), 'utf8')
|
||||
expect(version).toBe('7-neutralized\n')
|
||||
expect(version.trim()).not.toBe('7')
|
||||
})
|
||||
|
||||
itOnPosix('rejects interpreter candidates that are unusable in a shebang', () => {
|
||||
const userData = makeUserDataDir()
|
||||
const spaced = join(userData, 'a b')
|
||||
const dirNamedBash = join(userData, 'dirbin')
|
||||
mkdirSync(spaced, { recursive: true })
|
||||
mkdirSync(join(dirNamedBash, 'bash'), { recursive: true })
|
||||
writeFileSync(join(spaced, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||
|
||||
expect(resolvePosixTombstoneInterpreter(spaced, [], 'linux')).toBeNull()
|
||||
expect(resolvePosixTombstoneInterpreter(dirNamedBash, [], 'linux')).toBeNull()
|
||||
})
|
||||
|
||||
itOnPosix('resolves the interpreter from absolute PATH entries only', () => {
|
||||
// Force the NixOS/Guix fallback while excluding cwd-based interpreter lookup.
|
||||
const userData = makeUserDataDir()
|
||||
const absDir = join(userData, 'absbin')
|
||||
const relDir = join(userData, 'relbin')
|
||||
mkdirSync(absDir, { recursive: true })
|
||||
mkdirSync(relDir, { recursive: true })
|
||||
for (const dir of [absDir, relDir]) {
|
||||
writeFileSync(join(dir, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||
}
|
||||
|
||||
expect(resolvePosixTombstoneInterpreter(`${absDir}:/usr/bin`, [])).toBe(join(absDir, 'bash'))
|
||||
expect(resolvePosixTombstoneInterpreter(`:relbin:${absDir}`, [])).toBe(join(absDir, 'bash'))
|
||||
// Relative PATH resolves from process cwd; a tmpdir-only fixture would pass without the guard.
|
||||
const cwdRelName = `.orca-interp-${process.pid}`
|
||||
const cwdRelDir = join(process.cwd(), cwdRelName)
|
||||
mkdirSync(cwdRelDir, { recursive: true })
|
||||
try {
|
||||
writeFileSync(join(cwdRelDir, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||
expect(resolvePosixTombstoneInterpreter(`.:${cwdRelName}`, [])).toBeNull()
|
||||
} finally {
|
||||
rmSync(cwdRelDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
itOnPosix('resolves its own directory even when CDPATH is set', () => {
|
||||
// CDPATH can redirect relative cd and echo into wrapper_dir, breaking lookup.
|
||||
const userData = makeUserDataDir()
|
||||
@@ -184,36 +92,6 @@ describe('legacy terminal shim neutralization', () => {
|
||||
expect(wrapper).toContain('CDPATH= cd -P --')
|
||||
})
|
||||
|
||||
itOnPosix('rejects a distinct legacy shim directory named by the environment', () => {
|
||||
// An inherited marker can name an older install, distinct from the current wrapper directory.
|
||||
const userData = makeUserDataDir()
|
||||
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
const legacyDir = join(userData, 'legacy-shim')
|
||||
const realBin = join(userData, 'real-bin')
|
||||
for (const dir of [posixDir, legacyDir, realBin]) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
writeFileSync(join(legacyDir, 'git'), "#!/bin/bash\nprintf 'HOSTILE\\n'\n", { mode: 0o755 })
|
||||
writeFileSync(join(realBin, 'git'), "#!/bin/bash\nprintf 'REAL\\n'\n", { mode: 0o755 })
|
||||
|
||||
const run = spawnSync(join(posixDir, 'git'), ['--version'], {
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: legacyDir,
|
||||
PATH: `${legacyDir}:${realBin}:/usr/bin:/bin`
|
||||
},
|
||||
encoding: 'utf8',
|
||||
timeout: 20_000
|
||||
})
|
||||
|
||||
expect(run.stdout).toContain('REAL')
|
||||
expect(run.stdout).not.toContain('HOSTILE')
|
||||
})
|
||||
|
||||
itOnPosix('rejects the legacy shim directory reached by a different spelling', () => {
|
||||
// Equivalent inode spellings must exclude the still-live legacy wrapper too.
|
||||
const userData = makeUserDataDir()
|
||||
@@ -247,144 +125,6 @@ describe('legacy terminal shim neutralization', () => {
|
||||
}
|
||||
})
|
||||
|
||||
itOnPosix('reuses the replaced wrapper shebang when no absolute bash is verifiable', () => {
|
||||
// Deleting a hashed wrapper strands existing shells at 127; preserve its verified interpreter.
|
||||
const userData = makeUserDataDir()
|
||||
const wrapper = join(userData, 'git')
|
||||
writeFileSync(wrapper, '#!/bin/bash\nexit 0\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBe('/bin/bash')
|
||||
|
||||
// env bash defers interpreter lookup to PATH and reopens cwd hijacking.
|
||||
writeFileSync(wrapper, '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
|
||||
|
||||
// /bin/sh reliably exists but cannot run this Bash-specific body.
|
||||
writeFileSync(wrapper, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
|
||||
|
||||
writeFileSync(wrapper, '#!bash\nexit 0\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
|
||||
|
||||
writeFileSync(wrapper, '#!/nonexistent/bash\nexit 0\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
|
||||
|
||||
writeFileSync(wrapper, 'no shebang at all\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
|
||||
|
||||
expect(readVerifiedShebangInterpreter(join(userData, 'absent'))).toBeNull()
|
||||
})
|
||||
|
||||
itOnPosix('keeps the POSIX wrapper on a host where no absolute bash resolves', async () => {
|
||||
// Force resolver failure: ordinary CI bash paths otherwise hide missing fallback wiring.
|
||||
vi.resetModules()
|
||||
vi.doMock('./legacy-terminal-posix-tombstone', async () => {
|
||||
const actual = await vi.importActual<typeof PosixTombstoneModule>(
|
||||
'./legacy-terminal-posix-tombstone'
|
||||
)
|
||||
return { ...actual, resolvePosixTombstoneInterpreter: () => null }
|
||||
})
|
||||
try {
|
||||
const shimDir = await import('./legacy-terminal-shim-dir')
|
||||
shimDir.__resetLegacyTerminalShimNeutralizationForTests()
|
||||
const userData = makeUserDataDir()
|
||||
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
writeFileSync(join(posixDir, 'git'), '#!/bin/bash\nlegacy attribution wrapper\n', {
|
||||
mode: 0o755
|
||||
})
|
||||
// An unverifiable env shebang must be deleted rather than preserve retired attribution.
|
||||
writeFileSync(join(posixDir, 'gh'), '#!/usr/bin/env bash\nlegacy\n', { mode: 0o755 })
|
||||
|
||||
shimDir.neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
const git = readFileSync(join(posixDir, 'git'), 'utf8')
|
||||
expect(git.split('\n')[0]).toBe('#!/bin/bash')
|
||||
expect(git).toContain('Orca compatibility wrapper could not locate')
|
||||
expect(existsSync(join(posixDir, 'gh'))).toBe(false)
|
||||
} finally {
|
||||
vi.doUnmock('./legacy-terminal-posix-tombstone')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not collapse .. when classifying a shim PATH entry', () => {
|
||||
// Collapsing .. through a symlink can erase a legitimate PATH entry; remote/WSL paths cannot be resolved locally.
|
||||
expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/posix/../posix')).toBe(
|
||||
false
|
||||
)
|
||||
expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/posix')).toBe(true)
|
||||
expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/win32//')).toBe(true)
|
||||
expect(isLegacyTerminalShimPathEntry('/usr/local/bin')).toBe(false)
|
||||
})
|
||||
|
||||
it('strips a captured shim directory spelled with repeated separators', () => {
|
||||
// Repeated separators must not defeat captured-directory removal.
|
||||
const posixEnv = {
|
||||
PATH: '/custom/elsewhere///:/usr/bin',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/custom/elsewhere'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(posixEnv, 'linux')
|
||||
expect(posixEnv.PATH).toBe('/usr/bin')
|
||||
|
||||
const windowsEnv = {
|
||||
Path: 'C:\\Custom\\Else\\\\;C:\\Windows',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\Custom\\Else'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(windowsEnv, 'win32')
|
||||
expect(windowsEnv.Path).toBe('C:\\Windows')
|
||||
})
|
||||
|
||||
itOnPosix(
|
||||
'ignores a relative legacy shim directory instead of resolving it against the cwd',
|
||||
() => {
|
||||
// Relative -ef operands depend on cwd and could select different real git binaries.
|
||||
const userData = makeUserDataDir()
|
||||
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
const safeBin = join(userData, 'safe-bin')
|
||||
const laterBin = join(userData, 'later-bin')
|
||||
for (const dir of [posixDir, safeBin, laterBin]) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
writeFileSync(join(safeBin, 'git'), "#!/bin/bash\nprintf 'SAFE\\n'\n", { mode: 0o755 })
|
||||
writeFileSync(join(laterBin, 'git'), "#!/bin/bash\nprintf 'LATER\\n'\n", { mode: 0o755 })
|
||||
|
||||
const outputs = [userData, join(userData, 'orca-terminal-attribution')].map((cwd) => {
|
||||
const run = spawnSync(join(posixDir, 'git'), ['--version'], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: 'safe-bin',
|
||||
PATH: `${safeBin}:${laterBin}:/usr/bin:/bin`
|
||||
},
|
||||
encoding: 'utf8',
|
||||
timeout: 20_000
|
||||
})
|
||||
return run.stdout.trim()
|
||||
})
|
||||
|
||||
expect(outputs[0]).toBe(outputs[1])
|
||||
expect(outputs[0]).toBe('SAFE')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a POSIX directory whose name ends in a backslash', () => {
|
||||
// POSIX backslashes belong to filenames; stripping them can delete a real PATH entry.
|
||||
const env = {
|
||||
PATH: '/tmp/captured\\',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/captured'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
expect(env.PATH).toBe('/tmp/captured\\')
|
||||
|
||||
const windowsEnv = { Path: 'C:\\captured\\', ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\captured' }
|
||||
stripLegacyTerminalShimEnv(windowsEnv, 'win32')
|
||||
expect(windowsEnv.Path).toBeUndefined()
|
||||
})
|
||||
|
||||
itOnPosix('does not fall back to the cwd when every PATH entry is filtered out', () => {
|
||||
// An emptied PATH must not fall through to cwd-local git.
|
||||
const userData = makeUserDataDir()
|
||||
@@ -409,32 +149,6 @@ describe('legacy terminal shim neutralization', () => {
|
||||
expect(run.status).toBe(127)
|
||||
})
|
||||
|
||||
itOnPosix('hands the child the cleaned PATH, not the inherited one', () => {
|
||||
// Export the cleaned PATH so git hooks and credential helpers cannot rediscover legacy or cwd entries.
|
||||
const userData = makeUserDataDir()
|
||||
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
const realBin = join(userData, 'real-bin')
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
mkdirSync(realBin, { recursive: true })
|
||||
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
writeFileSync(join(realBin, 'git'), '#!/bin/bash\nprintf \'PATH=%s\\n\' "$PATH"\n', {
|
||||
mode: 0o755
|
||||
})
|
||||
|
||||
const run = spawnSync(join(posixDir, 'git'), ['--version'], {
|
||||
env: { PATH: `${posixDir}:.:${realBin}:/usr/bin:/bin` },
|
||||
encoding: 'utf8',
|
||||
timeout: 20_000
|
||||
})
|
||||
|
||||
expect(run.stdout).toContain('PATH=')
|
||||
expect(run.stdout).not.toContain('orca-terminal-attribution')
|
||||
expect(run.stdout.split('PATH=')[1]?.split(':')).not.toContain('.')
|
||||
})
|
||||
|
||||
itOnPosix('does not let the cwd supply the script interpreter', async () => {
|
||||
// Interpreter lookup precedes script PATH hygiene; env bash permits checkout-local interception.
|
||||
const userData = makeUserDataDir()
|
||||
@@ -528,109 +242,6 @@ describe('legacy terminal shim neutralization', () => {
|
||||
expect(noSlash.stdout).toContain('REAL')
|
||||
})
|
||||
|
||||
it('removes every Windows PATH occurrence of both captured wrapper directories', () => {
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8')
|
||||
const cmdCapture = 'set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"'
|
||||
expectOrdered(cmd, cmdCapture, 'set "ORCA_ATTRIBUTION_SHIM_DIR="')
|
||||
expect(cmd).toContain('for %%P in ("%PATH:;=" "%") do (')
|
||||
expect(cmd).toContain('if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b')
|
||||
expect(cmd).toContain('if defined orca_legacy_wrapper_dir call :orca_reject_legacy_dir')
|
||||
// cmd cannot use call :label && ...; preserve the explicit flag.
|
||||
expect(cmd).toContain('if defined orca_skip_entry exit /b')
|
||||
expect(cmd).not.toContain('call :orca_reject_legacy_dir &&')
|
||||
expect(cmd).toContain('if "%orca_path_entry_dir:~-1%."=="%orca_sep%."')
|
||||
|
||||
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
|
||||
expectOrdered(
|
||||
powershell,
|
||||
'$legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR',
|
||||
'Remove-Item "Env:$_"'
|
||||
)
|
||||
expect(powershell).toContain('$wrapperDirs = @($wrapperDir, $legacyWrapperDir)')
|
||||
expect(powershell).toContain("$env:PATH = (($env:PATH -split ';') | Where-Object {")
|
||||
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
|
||||
})
|
||||
|
||||
it('leaves an install that never ran the shim untouched', () => {
|
||||
// Fresh installs need no hashed-path compatibility wrappers.
|
||||
const userData = makeUserDataDir()
|
||||
|
||||
expect(() => neutralizeLegacyTerminalShimDir(userData)).not.toThrow()
|
||||
expect(existsSync(join(userData, 'orca-terminal-attribution'))).toBe(false)
|
||||
})
|
||||
|
||||
itOnPosixNonRoot('retries a startup failure in-process and latches after success', async () => {
|
||||
vi.useFakeTimers()
|
||||
const userData = makeUserDataDir()
|
||||
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
const gitWrapper = join(posixDir, 'git')
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
writeFileSync(gitWrapper, 'legacy attribution wrapper')
|
||||
chmodSync(posixDir, 0o500)
|
||||
try {
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
expect(readFileSync(gitWrapper, 'utf8')).toBe('legacy attribution wrapper')
|
||||
} finally {
|
||||
chmodSync(posixDir, 0o700)
|
||||
}
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(readFileSync(gitWrapper, 'utf8')).not.toContain('legacy attribution wrapper')
|
||||
|
||||
writeFileSync(gitWrapper, 'recreated after success')
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
expect(readFileSync(gitWrapper, 'utf8')).toBe('recreated after success')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
itOnPosixNonRoot('warns and stops retrying once the ladder is exhausted', async () => {
|
||||
vi.useFakeTimers()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const userData = makeUserDataDir()
|
||||
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
|
||||
chmodSync(posixDir, 0o500)
|
||||
try {
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
// Cover all four retry delays plus enough slack to expose an unwanted fifth.
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
|
||||
expect(readFileSync(join(posixDir, 'git'), 'utf8')).toBe('legacy attribution wrapper')
|
||||
const messages = warn.mock.calls.map((call) => String(call[0]))
|
||||
expect(messages.filter((message) => message.includes('neutralization attempt'))).toHaveLength(
|
||||
5
|
||||
)
|
||||
// Counts alone cannot catch incorrect attempt ordinals.
|
||||
expect(messages.some((message) => message.includes('neutralization attempt 1 failed'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(messages.some((message) => message.includes('neutralization attempt 5 failed'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
messages.some((message) => message.includes('gave up neutralizing after 5 attempts'))
|
||||
).toBe(true)
|
||||
|
||||
warn.mockClear()
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
chmodSync(posixDir, 0o700)
|
||||
warn.mockRestore()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
itOnPosix('keeps a real Bash command hash working with trailing PATH separators', async () => {
|
||||
const userData = makeUserDataDir()
|
||||
const shimDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
@@ -688,138 +299,6 @@ describe('legacy terminal shim neutralization', () => {
|
||||
expect(stdout).not.toContain('Co-authored-by: Orca')
|
||||
expect(stderr).toBe('fixture stderr\n')
|
||||
})
|
||||
|
||||
it('drops inherited shim env and its PATH entry without touching real entries', () => {
|
||||
const env: Record<string, string> = {
|
||||
PATH: `/home/u/.orca/orca-terminal-attribution/posix:/usr/local/bin:/usr/bin`,
|
||||
ORCA_ENABLE_GIT_ATTRIBUTION: '1',
|
||||
ORCA_GIT_COMMIT_TRAILER: 'Co-authored-by: Orca <help@stably.ai>',
|
||||
ORCA_GH_PR_FOOTER: 'footer',
|
||||
ORCA_GH_ISSUE_FOOTER: 'footer',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/home/u/.orca/orca-terminal-attribution/posix',
|
||||
ORCA_REAL_GIT: '/usr/bin/git',
|
||||
ORCA_REAL_GH: '/usr/bin/gh',
|
||||
HOME: '/home/u'
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
|
||||
expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin', HOME: '/home/u' })
|
||||
})
|
||||
|
||||
it('strips the captured shim directory when PATH spells it with a trailing separator', () => {
|
||||
// Separator variants must not keep the captured shim directory reachable.
|
||||
const posix: Record<string, string> = {
|
||||
PATH: '/custom/elsewhere/:/usr/bin',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/custom/elsewhere'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(posix, 'linux')
|
||||
expect(posix.PATH).toBe('/usr/bin')
|
||||
|
||||
const win: Record<string, string> = {
|
||||
Path: 'C:\\Custom\\Else;C:\\Windows',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\Custom\\Else\\'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(win, 'win32')
|
||||
expect(win.Path).toBe('C:\\Windows')
|
||||
})
|
||||
|
||||
it('uses the captured POSIX shim directory literally when it contains a colon', () => {
|
||||
const shimDir = '/tmp/orca:user/orca-terminal-attribution/posix'
|
||||
const env: Record<string, string> = {
|
||||
PATH: `/usr/local/bin:${shimDir}:/usr/bin`,
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: shimDir
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
|
||||
expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin' })
|
||||
})
|
||||
|
||||
it('treats legacy Windows environment keys case-insensitively', () => {
|
||||
const shimDir = 'C:\\Users\\orca;user\\orca-terminal-attribution\\win32'
|
||||
const env: Record<string, string> = {
|
||||
Path: `${shimDir};C:\\Windows\\System32`,
|
||||
orca_attribution_shim_dir: shimDir,
|
||||
Orca_Enable_Git_Attribution: '1',
|
||||
orca_real_git: 'C:\\Git\\git.exe'
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'win32')
|
||||
|
||||
expect(env).toEqual({ Path: 'C:\\Windows\\System32' })
|
||||
})
|
||||
|
||||
it('strips legacy entries from every Windows PATH spelling', () => {
|
||||
const env: Record<string, string> = {
|
||||
PATH: 'C:\\Orca\\orca-terminal-attribution\\win32',
|
||||
Path: 'C:\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32'
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'win32')
|
||||
|
||||
expect(env.PATH).toBeUndefined()
|
||||
expect(env.Path).toBe('C:\\Windows\\System32')
|
||||
})
|
||||
|
||||
it('matches a re-cased Windows shim path', () => {
|
||||
const env: Record<string, string> = {
|
||||
Path: 'C:\\Orca\\Orca-Terminal-Attribution\\Win32;C:\\Windows\\System32'
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'win32')
|
||||
|
||||
expect(env.Path).toBe('C:\\Windows\\System32')
|
||||
})
|
||||
|
||||
it('preserves explicit empty PATH values', () => {
|
||||
const windowsEnv: Record<string, string> = { PATH: '', Path: 'C:\\Windows' }
|
||||
const posixEnv: Record<string, string> = { PATH: '' }
|
||||
|
||||
stripLegacyTerminalShimEnv(windowsEnv, 'win32')
|
||||
stripLegacyTerminalShimEnv(posixEnv, 'linux')
|
||||
|
||||
expect(windowsEnv).toEqual({ PATH: '', Path: 'C:\\Windows' })
|
||||
expect(posixEnv).toEqual({ PATH: '' })
|
||||
})
|
||||
|
||||
it('strips legacy shim entries that carry a trailing separator', () => {
|
||||
const posix: Record<string, string> = {
|
||||
PATH: '/home/u/.orca/orca-terminal-attribution/posix/:/usr/bin'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(posix, 'linux')
|
||||
expect(posix.PATH).toBe('/usr/bin')
|
||||
|
||||
const many: Record<string, string> = {
|
||||
PATH: '/home/u/.orca/orca-terminal-attribution/posix///:/usr/bin'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(many, 'linux')
|
||||
expect(many.PATH).toBe('/usr/bin')
|
||||
|
||||
const win: Record<string, string> = {
|
||||
Path: 'C:\\Users\\u\\orca-terminal-attribution\\win32\\;C:\\Windows'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(win, 'win32')
|
||||
expect(win.Path).toBe('C:\\Windows')
|
||||
})
|
||||
|
||||
it('keeps neighbouring directories that merely share the name prefix', () => {
|
||||
const env: Record<string, string> = {
|
||||
PATH: '/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
|
||||
}
|
||||
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
|
||||
expect(env.PATH).toBe(
|
||||
'/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an unrelated PATH untouched', () => {
|
||||
const env: Record<string, string> = { PATH: '/usr/local/bin:/usr/bin' }
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
expect(env.PATH).toBe('/usr/local/bin:/usr/bin')
|
||||
})
|
||||
})
|
||||
|
||||
function quoteBash(value: string): string {
|
||||
|
||||
@@ -121,74 +121,6 @@ describePosix('OMP shell wrapper node-pty reproduction', () => {
|
||||
}
|
||||
})
|
||||
|
||||
itWithBash('reproduces why restored shells miss OMP status without the wrapper', async () => {
|
||||
const tempDir = makeTempDir()
|
||||
const binDir = join(tempDir, 'bin')
|
||||
const piDir = join(tempDir, 'pi-agent')
|
||||
const ompDir = join(tempDir, 'omp-agent')
|
||||
const extensionDir = join(ompDir, 'extensions')
|
||||
mkdirSync(binDir)
|
||||
mkdirSync(piDir)
|
||||
mkdirSync(extensionDir, { recursive: true })
|
||||
const statusExtension = join(extensionDir, 'orca-agent-status.ts')
|
||||
writeFileSync(statusExtension, 'export default {}')
|
||||
writeFakeOmp(binDir)
|
||||
|
||||
const makeEnv = (captureFile: string, afterPiFile: string): Record<string, string> => ({
|
||||
...process.env,
|
||||
HOME: tempDir,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
PI_CODING_AGENT_DIR: '',
|
||||
ORCA_PI_CODING_AGENT_DIR: '',
|
||||
ORCA_OMP_CODING_AGENT_DIR: '',
|
||||
ORCA_OMP_STATUS_EXTENSION: statusExtension,
|
||||
ORCA_FAKE_OMP_DEFAULT_DIR: ompDir,
|
||||
ORCA_CAPTURE_FILE: captureFile,
|
||||
ORCA_AFTER_PI_FILE: afterPiFile,
|
||||
TERM: process.env.TERM || 'xterm-256color'
|
||||
})
|
||||
|
||||
const unwrappedCapture = join(tempDir, 'unwrapped-capture')
|
||||
const unwrappedAfterPi = join(tempDir, 'unwrapped-after-pi')
|
||||
await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: '',
|
||||
env: makeEnv(unwrappedCapture, unwrappedAfterPi),
|
||||
input: `omp ask
|
||||
printf '%s' "$PI_CODING_AGENT_DIR" > "$ORCA_AFTER_PI_FILE"
|
||||
exit 0
|
||||
`
|
||||
})
|
||||
|
||||
const unwrapped = readFileSync(unwrappedCapture, 'utf8')
|
||||
expect(unwrapped).toContain('PI=\n')
|
||||
expect(unwrapped).toContain(`EFFECTIVE=${ompDir}`)
|
||||
expect(unwrapped).toContain('ARG1=ask')
|
||||
expect(unwrapped).not.toContain('ARG1=--extension')
|
||||
|
||||
const wrappedCapture = join(tempDir, 'wrapped-capture')
|
||||
const wrappedAfterPi = join(tempDir, 'wrapped-after-pi')
|
||||
const wrappedOutput = await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: getPosixOmpShellWrapper(),
|
||||
env: makeEnv(wrappedCapture, wrappedAfterPi),
|
||||
input: `type omp
|
||||
omp ask
|
||||
printf '%s' "$PI_CODING_AGENT_DIR" > "$ORCA_AFTER_PI_FILE"
|
||||
exit 0
|
||||
`
|
||||
})
|
||||
|
||||
const wrapped = readFileSync(wrappedCapture, 'utf8')
|
||||
expect(wrappedOutput).toContain('omp is a function')
|
||||
expect(wrapped).toContain('PI=\n')
|
||||
expect(wrapped).toContain(`EFFECTIVE=${ompDir}`)
|
||||
expect(wrapped).toContain('ARG1=--extension')
|
||||
expect(wrapped).toContain(`ARG2=${statusExtension}`)
|
||||
expect(wrapped).toContain('ARG3=ask')
|
||||
expect(readFileSync(wrappedAfterPi, 'utf8')).toBe('')
|
||||
})
|
||||
|
||||
itWithBash('runs OMP config subcommands without redirecting the home', async () => {
|
||||
const tempDir = makeTempDir()
|
||||
const binDir = join(tempDir, 'bin')
|
||||
@@ -230,105 +162,6 @@ exit 0
|
||||
expect(readFileSync(join(sourceDir, 'config.yml'), 'utf8')).toBe('updated-by-omp-config\n')
|
||||
})
|
||||
|
||||
itWithBash.each([
|
||||
'__complete',
|
||||
'bench',
|
||||
'completions',
|
||||
'dry-balance',
|
||||
'gallery',
|
||||
'install',
|
||||
'join',
|
||||
'models',
|
||||
'say',
|
||||
'tiny-models',
|
||||
'token',
|
||||
'ttsr',
|
||||
'usage'
|
||||
])('runs OMP %s subcommands without injecting the status extension', async (subcommand) => {
|
||||
const tempDir = makeTempDir()
|
||||
const binDir = join(tempDir, 'bin')
|
||||
const sourceDir = join(tempDir, 'source-omp-agent')
|
||||
const extensionDir = join(sourceDir, 'extensions')
|
||||
mkdirSync(binDir)
|
||||
mkdirSync(sourceDir, { recursive: true })
|
||||
mkdirSync(extensionDir, { recursive: true })
|
||||
const statusExtension = join(extensionDir, 'orca-agent-status.ts')
|
||||
writeFileSync(statusExtension, 'export default {}')
|
||||
writeFakeOmp(binDir)
|
||||
|
||||
const captureFile = join(tempDir, `${subcommand}-capture`)
|
||||
await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: getPosixOmpShellWrapper(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: tempDir,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
PI_CODING_AGENT_DIR: '',
|
||||
ORCA_PI_CODING_AGENT_DIR: '',
|
||||
ORCA_OMP_CODING_AGENT_DIR: '',
|
||||
ORCA_OMP_SOURCE_AGENT_DIR: sourceDir,
|
||||
ORCA_OMP_STATUS_EXTENSION: statusExtension,
|
||||
ORCA_FAKE_OMP_DEFAULT_DIR: sourceDir,
|
||||
ORCA_CAPTURE_FILE: captureFile,
|
||||
TERM: process.env.TERM || 'xterm-256color'
|
||||
},
|
||||
input: `omp ${subcommand}
|
||||
exit 0
|
||||
`
|
||||
})
|
||||
|
||||
const capture = readFileSync(captureFile, 'utf8')
|
||||
expect(capture).toContain('PI=\n')
|
||||
expect(capture).toContain(`EFFECTIVE=${sourceDir}`)
|
||||
expect(capture).toContain(`ARG1=${subcommand}`)
|
||||
expect(capture).not.toContain('ARG1=--extension')
|
||||
})
|
||||
|
||||
itWithBash(
|
||||
'lets OMP config subcommands fall back to the default home without a source shadow',
|
||||
async () => {
|
||||
const tempDir = makeTempDir()
|
||||
const binDir = join(tempDir, 'bin')
|
||||
const defaultOmpDir = join(tempDir, '.omp', 'agent')
|
||||
const extensionDir = join(defaultOmpDir, 'extensions')
|
||||
mkdirSync(binDir)
|
||||
mkdirSync(defaultOmpDir, { recursive: true })
|
||||
mkdirSync(extensionDir, { recursive: true })
|
||||
const statusExtension = join(extensionDir, 'orca-agent-status.ts')
|
||||
writeFileSync(statusExtension, 'export default {}')
|
||||
writeFakeOmp(binDir)
|
||||
|
||||
const captureFile = join(tempDir, 'default-config-capture')
|
||||
await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: getPosixOmpShellWrapper(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: tempDir,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
PI_CODING_AGENT_DIR: '',
|
||||
ORCA_PI_CODING_AGENT_DIR: '',
|
||||
ORCA_OMP_CODING_AGENT_DIR: '',
|
||||
ORCA_OMP_STATUS_EXTENSION: statusExtension,
|
||||
ORCA_FAKE_OMP_DEFAULT_DIR: defaultOmpDir,
|
||||
ORCA_CAPTURE_FILE: captureFile,
|
||||
TERM: process.env.TERM || 'xterm-256color'
|
||||
},
|
||||
input: `omp config
|
||||
exit 0
|
||||
`
|
||||
})
|
||||
|
||||
const capture = readFileSync(captureFile, 'utf8')
|
||||
expect(capture).toContain('PI=\n')
|
||||
expect(capture).toContain(`EFFECTIVE=${defaultOmpDir}`)
|
||||
expect(readFileSync(join(defaultOmpDir, 'config.yml'), 'utf8')).toBe(
|
||||
'updated-by-omp-config\n'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
async function expectStaleCwdRecovery(shell: PosixShell): Promise<void> {
|
||||
const tempDir = makeTempDir()
|
||||
const workspaceDir = join(tempDir, 'workspace')
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const lineEditorProbe = vi.hoisted(() => vi.fn())
|
||||
const relayedLineEditorProbe = vi.hoisted(() => vi.fn())
|
||||
const listChildPids = vi.hoisted(() => vi.fn())
|
||||
const processReadinessProbe = vi.hoisted(() => vi.fn())
|
||||
const resolveExecutablePath = vi.hoisted(() => vi.fn((value: string) => Promise.resolve(value)))
|
||||
const resolveInstalledExecutablePaths = vi.hoisted(() =>
|
||||
vi.fn((): Promise<string[]> => Promise.resolve([]))
|
||||
)
|
||||
vi.mock('../../../shared/terminal/pty/native-process/pty-slave-line-discipline-echo', () => ({
|
||||
createPtySlaveLineEditorProbe: (ttyPath: string) =>
|
||||
ttyPath === '/dev/ttys099' ? relayedLineEditorProbe : lineEditorProbe
|
||||
}))
|
||||
vi.mock('../../../shared/terminal/pty/native-process/shell-process-readiness', () => ({
|
||||
readShellProcessReadiness: processReadinessProbe,
|
||||
listChildProcessIds: listChildPids,
|
||||
resolveShellExecutablePath: resolveExecutablePath,
|
||||
resolveInstalledShellExecutablePaths: resolveInstalledExecutablePaths
|
||||
}))
|
||||
|
||||
import { createShellPromptReadinessProbe } from './shell-prompt-readiness-probe'
|
||||
|
||||
describe('shell prompt readiness probe', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
lineEditorProbe.mockReset()
|
||||
relayedLineEditorProbe.mockReset()
|
||||
listChildPids.mockReset().mockResolvedValue([])
|
||||
processReadinessProbe.mockReset()
|
||||
resolveExecutablePath.mockClear()
|
||||
resolveInstalledExecutablePaths.mockClear()
|
||||
resolveInstalledExecutablePaths.mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('accepts only the identified shell pid in line-editor mode and foreground', async () => {
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(onPromptReady).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves an unset child PATH for executable resolution', async () => {
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady: vi.fn(),
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(resolveExecutablePath).toHaveBeenCalledWith('/bin/zsh', process.cwd(), undefined)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['silent read', 'other', { executablePath: '/bin/zsh', foreground: true }],
|
||||
['background shell', 'line-editor', { executablePath: '/bin/zsh', foreground: false }],
|
||||
['different foreground process', 'line-editor', null],
|
||||
[
|
||||
'non-shell replacement image',
|
||||
'line-editor',
|
||||
{ executablePath: '/usr/bin/sqlite3', foreground: true }
|
||||
],
|
||||
[
|
||||
'non-shell image with the shell basename',
|
||||
'line-editor',
|
||||
{ executablePath: '/tmp/zsh', foreground: true }
|
||||
]
|
||||
])('rejects %s', async (_name, terminalState, rows) => {
|
||||
lineEditorProbe.mockResolvedValue(terminalState)
|
||||
processReadinessProbe.mockResolvedValue(rows)
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(onPromptReady).not.toHaveBeenCalled()
|
||||
if (terminalState === 'other') {
|
||||
expect(listChildPids).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['accepts', 'line-editor', true],
|
||||
['rejects', 'other', false]
|
||||
])(
|
||||
'%s a relayed shell whose inner pty is in %s mode (#25586)',
|
||||
async (_verb, innerState, expected) => {
|
||||
lineEditorProbe.mockResolvedValue('other')
|
||||
relayedLineEditorProbe.mockResolvedValue(innerState)
|
||||
listChildPids.mockResolvedValue([43])
|
||||
processReadinessProbe.mockImplementation((pid: number) =>
|
||||
Promise.resolve(
|
||||
pid === 42
|
||||
? { executablePath: '/usr/local/bin/kiro-cli-term', foreground: true, ttyPath: null }
|
||||
: { executablePath: '/bin/zsh', foreground: true, ttyPath: '/dev/ttys099' }
|
||||
)
|
||||
)
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(onPromptReady).toHaveBeenCalledTimes(expected ? 1 : 0)
|
||||
}
|
||||
)
|
||||
|
||||
it('accepts a second installation of the same shell that the pane PATH resolves', async () => {
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockResolvedValue({
|
||||
executablePath: '/opt/homebrew/bin/bash',
|
||||
foreground: true
|
||||
})
|
||||
resolveInstalledExecutablePaths.mockResolvedValue(['/bin/bash', '/opt/homebrew/bin/bash'])
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/bash',
|
||||
shellCwd: '/work',
|
||||
shellPathEnv: '/opt/homebrew/bin:/usr/bin:/bin',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(resolveInstalledExecutablePaths).toHaveBeenCalledWith(
|
||||
'bash',
|
||||
'/work',
|
||||
'/opt/homebrew/bin:/usr/bin:/bin'
|
||||
)
|
||||
expect(onPromptReady).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects a replacement with the shell basename that the pane PATH cannot reach', async () => {
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockResolvedValue({ executablePath: '/tmp/bash', foreground: true })
|
||||
resolveInstalledExecutablePaths.mockResolvedValue(['/bin/bash', '/opt/homebrew/bin/bash'])
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/bash',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(onPromptReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not widen identity when the launched shell path resolves exactly', async () => {
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady: vi.fn(),
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(resolveInstalledExecutablePaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates an alternate-installation result that resolves after disposal', async () => {
|
||||
const pending: { resolve?: (value: string[]) => void } = {}
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockResolvedValue({
|
||||
executablePath: '/opt/homebrew/bin/bash',
|
||||
foreground: true
|
||||
})
|
||||
resolveInstalledExecutablePaths.mockImplementation(
|
||||
() => new Promise((resolve) => (pending.resolve = resolve))
|
||||
)
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/bash',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
probe?.dispose()
|
||||
pending.resolve?.(['/opt/homebrew/bin/bash'])
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(onPromptReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does no external work when the ready marker cancels the settle window', async () => {
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady: vi.fn(),
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
probe?.dispose()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(lineEditorProbe).not.toHaveBeenCalled()
|
||||
expect(processReadinessProbe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates an in-flight result when newer output arrives', async () => {
|
||||
const pending: { resolve?: (value: string) => void } = {}
|
||||
lineEditorProbe.mockImplementation(
|
||||
() => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void))
|
||||
)
|
||||
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
pending.resolve?.('line-editor')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(onPromptReady).not.toHaveBeenCalled()
|
||||
expect(processReadinessProbe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not inspect a process after disposal during a line-editor probe', async () => {
|
||||
const pending: { resolve?: (value: string) => void } = {}
|
||||
lineEditorProbe.mockImplementation(
|
||||
() => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void))
|
||||
)
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady: vi.fn(),
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
probe?.dispose()
|
||||
pending.resolve?.('line-editor')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(processReadinessProbe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates process readiness that resolves after disposal', async () => {
|
||||
const pending: { resolve?: (value: { executablePath: string; foreground: boolean }) => void } =
|
||||
{}
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockImplementation(
|
||||
() => new Promise((resolve) => (pending.resolve = resolve))
|
||||
)
|
||||
const onPromptReady = vi.fn()
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady,
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
probe?.dispose()
|
||||
pending.resolve?.({ executablePath: '/bin/zsh', foreground: true })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(onPromptReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores slow startup output until the line editor enables its protocol', async () => {
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady: vi.fn(),
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
probe?.notifyOutput(`startup ${index}\n`)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
}
|
||||
|
||||
expect(lineEditorProbe).not.toHaveBeenCalled()
|
||||
expect(processReadinessProbe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds rejected line-editor retries', async () => {
|
||||
lineEditorProbe.mockResolvedValue('line-editor')
|
||||
processReadinessProbe.mockResolvedValue({
|
||||
executablePath: '/usr/bin/sqlite3',
|
||||
foreground: true
|
||||
})
|
||||
const probe = createShellPromptReadinessProbe({
|
||||
slavePath: '/dev/ttys048',
|
||||
shellPath: '/bin/zsh',
|
||||
getShellPid: () => 42,
|
||||
onPromptReady: vi.fn(),
|
||||
settleMs: 10
|
||||
})
|
||||
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
probe?.notifyOutput('\x1b[?2004h')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
}
|
||||
|
||||
expect(lineEditorProbe).toHaveBeenCalledTimes(4)
|
||||
expect(processReadinessProbe).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
@@ -1,547 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { existsSyncMock, readFileSyncMock, readdirSyncMock } = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
readFileSyncMock: vi.fn(),
|
||||
readdirSyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
readdirSync: readdirSyncMock
|
||||
}))
|
||||
|
||||
import {
|
||||
__resetShellStartupEnvCache,
|
||||
readBashStartupEnvVar,
|
||||
readSessionShellStartupEnvVar,
|
||||
readShellStartupEnvVar,
|
||||
SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES
|
||||
} from './shell-startup-env'
|
||||
|
||||
describe('readShellStartupEnvVar', () => {
|
||||
const originalPlatform = process.platform
|
||||
const originalShell = process.env.SHELL
|
||||
// CI may export XDG_CONFIG_HOME; pin it so fish mock paths do not depend on the host.
|
||||
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
existsSyncMock.mockReset()
|
||||
readFileSyncMock.mockReset()
|
||||
readdirSyncMock.mockReset()
|
||||
readdirSyncMock.mockImplementation(() => {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
__resetShellStartupEnvCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
if (originalShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = originalShell
|
||||
}
|
||||
if (originalXdgConfigHome === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = originalXdgConfigHome
|
||||
}
|
||||
})
|
||||
|
||||
function mockStartupFiles(files: Record<string, string>) {
|
||||
const hasAbsoluteKeys = Object.keys(files).some((path) => path.startsWith('/'))
|
||||
existsSyncMock.mockImplementation((p: string) => {
|
||||
const file = p.split('/').pop() ?? ''
|
||||
return p in files || (!hasAbsoluteKeys && file in files)
|
||||
})
|
||||
readFileSyncMock.mockImplementation((p: string) => {
|
||||
const file = p.split('/').pop() ?? ''
|
||||
if (p in files) {
|
||||
return files[p]
|
||||
}
|
||||
if (!hasAbsoluteKeys && file in files) {
|
||||
return files[file]
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
}
|
||||
|
||||
// #1534: GUI env lacks rc-only exports; static discovery must preserve the user overlay source.
|
||||
it('mirrors the user scenario: GUI-launched Orca discovers .zshrc-only export', () => {
|
||||
mockStartupFiles({
|
||||
'.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.config/opencode"\n'
|
||||
})
|
||||
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe(
|
||||
'/home/alice/.config/opencode'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined when HOME is unset', () => {
|
||||
const savedHome = process.env.HOME
|
||||
delete process.env.HOME
|
||||
try {
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR')).toBeUndefined()
|
||||
expect(existsSyncMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
if (savedHome !== undefined) {
|
||||
process.env.HOME = savedHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('returns undefined on Windows', () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/win\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads Git Bash login files on Windows only when asked for bash', () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
process.env.SHELL = '/usr/bin/bash'
|
||||
mockStartupFiles({ '.bash_profile': 'export OPENCODE_CONFIG_DIR=/git-bash\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
|
||||
expect(readBashStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/git-bash')
|
||||
})
|
||||
|
||||
it('returns undefined when no startup file matches', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export FOO=bar\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the LAST assignment when multiple files re-export', () => {
|
||||
mockStartupFiles({
|
||||
'.zshenv': 'export OPENCODE_CONFIG_DIR="/old/zshenv"\n',
|
||||
'.zprofile': 'export OPENCODE_CONFIG_DIR="/middle/zprofile"\n',
|
||||
'.zshrc': 'export OPENCODE_CONFIG_DIR="/new/zshrc"\n',
|
||||
'.zlogin': 'export OPENCODE_CONFIG_DIR="/newest/zlogin"\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/newest/zlogin')
|
||||
})
|
||||
|
||||
it('uses ZDOTDIR exported from .zshenv for later zsh startup files', () => {
|
||||
mockStartupFiles({
|
||||
'/home/alice/.zshenv': 'export ZDOTDIR="$HOME/.config/zsh"\n',
|
||||
'/home/alice/.config/zsh/.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode"\n'
|
||||
})
|
||||
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/bin/zsh')).toBe(
|
||||
'/home/alice/company/opencode'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles double-quoted values', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/quoted/path"\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path')
|
||||
})
|
||||
|
||||
it('handles single-quoted values', () => {
|
||||
mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='/quoted/path'\n" })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path')
|
||||
})
|
||||
|
||||
it('handles unquoted values', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/unquoted/path\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/unquoted/path')
|
||||
})
|
||||
|
||||
it('expands $HOME in values', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.opencode"\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe(
|
||||
'/home/alice/.opencode'
|
||||
)
|
||||
})
|
||||
|
||||
it('expands ${HOME} in values', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="${HOME}/.opencode"\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe(
|
||||
'/home/alice/.opencode'
|
||||
)
|
||||
})
|
||||
|
||||
it('expands leading ~ in values', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=~/.opencode\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe(
|
||||
'/home/alice/.opencode'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores bare assignments without the export keyword', () => {
|
||||
// Unexported assignments never reach child processes and cannot be overlay sources.
|
||||
mockStartupFiles({ '.zshrc': 'OPENCODE_CONFIG_DIR=/no/export\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves a # inside a double-quoted value', () => {
|
||||
// Unquoted # begins a comment only at a word boundary.
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/path/with#hash"\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/path/with#hash')
|
||||
})
|
||||
|
||||
it('preserves a # inside a single-quoted value', () => {
|
||||
mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='/path/with#hash'\n" })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/path/with#hash')
|
||||
})
|
||||
|
||||
it('strips a trailing # comment from an unquoted value', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/bare/path # trailing comment\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/bare/path')
|
||||
})
|
||||
|
||||
it('strips a trailing # comment after a double-quoted value', () => {
|
||||
mockStartupFiles({
|
||||
'.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.opencode" # trailing comment\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe(
|
||||
'/home/alice/.opencode'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips a trailing # comment after a single-quoted value', () => {
|
||||
mockStartupFiles({
|
||||
'.zshrc': "export OPENCODE_CONFIG_DIR='/literal/path' # trailing comment\n"
|
||||
})
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/literal/path')
|
||||
})
|
||||
|
||||
it('does not expand $HOME inside single quotes', () => {
|
||||
mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='$HOME/.opencode'\n" })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('$HOME/.opencode')
|
||||
})
|
||||
|
||||
it('does not partially expand $HOMER / $HOMEPATH', () => {
|
||||
// HOME expansion must not partially rewrite a different variable name.
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOMER/agent"\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('$HOMER/agent')
|
||||
})
|
||||
|
||||
it('only scans zsh startup files when SHELL is zsh', () => {
|
||||
// Startup discovery must follow only the active shell family.
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
mockStartupFiles({
|
||||
'.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n',
|
||||
'.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/zshrc')
|
||||
})
|
||||
|
||||
it('only scans bash startup files when SHELL is bash', () => {
|
||||
process.env.SHELL = '/bin/bash'
|
||||
mockStartupFiles({
|
||||
'.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n',
|
||||
'.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/bash_profile')
|
||||
})
|
||||
|
||||
it('defaults to zsh startup files when SHELL is unset', () => {
|
||||
delete process.env.SHELL
|
||||
mockStartupFiles({
|
||||
'.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n',
|
||||
'.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/zshrc')
|
||||
})
|
||||
|
||||
it('does not scan .bashrc for bash shells', () => {
|
||||
// Orca's login Bash wrapper does not force .bashrc; discovery must match.
|
||||
process.env.SHELL = '/bin/bash'
|
||||
mockStartupFiles({ '.bashrc': 'export OPENCODE_CONFIG_DIR=/from/bashrc\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not scan zsh or bash files for an explicit unsupported shell', () => {
|
||||
mockStartupFiles({
|
||||
'.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n',
|
||||
'.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n'
|
||||
})
|
||||
expect(
|
||||
readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/opt/bin/fish')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('honors an explicit shell argument over process.env.SHELL', () => {
|
||||
// Per-spawn SHELL can differ from main's inherited SHELL.
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
mockStartupFiles({
|
||||
'.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n',
|
||||
'.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/bin/bash')).toBe(
|
||||
'/from/bash_profile'
|
||||
)
|
||||
})
|
||||
|
||||
it('memoizes results across calls within the same process', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/cached\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/cached')
|
||||
|
||||
const callsAfterFirst = readFileSyncMock.mock.calls.length
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/cached')
|
||||
expect(readFileSyncMock.mock.calls.length).toBe(callsAfterFirst)
|
||||
})
|
||||
|
||||
it('bounds cache keys from long-lived home and host churn', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/cached\n' })
|
||||
for (let index = 0; index < SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES + 40; index += 1) {
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', `/home/user-${index}`)).toBe('/cached')
|
||||
}
|
||||
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/user-0')).toBe('/cached')
|
||||
expect(readFileSyncMock.mock.calls.length).toBeGreaterThan(
|
||||
SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES + 40
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects names with regex metacharacters', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export FOO=/x\n' })
|
||||
expect(readShellStartupEnvVar('FOO.*', '/home/alice')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not match other variable names', () => {
|
||||
mockStartupFiles({
|
||||
'.zshrc': 'export OPENCODE_CONFIG_DIR_BACKUP=/backup\nexport NOT_OPENCODE_CONFIG_DIR=/x\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('survives a readFileSync error on one file and continues', () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockImplementation((p: string) => {
|
||||
if (p.endsWith('.zshenv')) {
|
||||
throw new Error('EACCES')
|
||||
}
|
||||
if (p.endsWith('.zshrc')) {
|
||||
return 'export OPENCODE_CONFIG_DIR=/found\n'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/found')
|
||||
})
|
||||
|
||||
it('handles CRLF line endings', () => {
|
||||
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/crlf\r\nexport OTHER=x\r\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/crlf')
|
||||
})
|
||||
|
||||
it('does not match an OPENCODE_CONFIG_DIR mention in a comment', () => {
|
||||
mockStartupFiles({ '.zshrc': '# export OPENCODE_CONFIG_DIR=/from-comment\n' })
|
||||
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('fish', () => {
|
||||
const FISH = '/opt/homebrew/bin/fish'
|
||||
|
||||
function mockFishFiles(files: Record<string, string>, snippets: string[] = []) {
|
||||
readdirSyncMock.mockImplementation((dir: string) => {
|
||||
if (dir === '/home/alice/.config/fish/conf.d' || dir === '/cfg/fish/conf.d') {
|
||||
return snippets
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
mockStartupFiles(files)
|
||||
}
|
||||
|
||||
it('reads an exported set from config.fish', () => {
|
||||
mockFishFiles({
|
||||
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /home/alice/.codex\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/home/alice/.codex')
|
||||
})
|
||||
|
||||
it('lets config.fish win over a conf.d snippet, matching fish source order', () => {
|
||||
mockFishFiles(
|
||||
{
|
||||
'/home/alice/.config/fish/conf.d/10-agents.fish': 'set -gx CODEX_HOME /from/confd\n',
|
||||
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/config\n'
|
||||
},
|
||||
['10-agents.fish']
|
||||
)
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/config')
|
||||
})
|
||||
|
||||
it('sources conf.d snippets in filename order', () => {
|
||||
mockFishFiles(
|
||||
{
|
||||
'/home/alice/.config/fish/conf.d/aaa.fish': 'set -gx CODEX_HOME /from/aaa\n',
|
||||
'/home/alice/.config/fish/conf.d/zzz.fish': 'set -gx CODEX_HOME /from/zzz\n'
|
||||
},
|
||||
['zzz.fish', 'aaa.fish', 'notes.txt']
|
||||
)
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/zzz')
|
||||
})
|
||||
|
||||
it('honors XDG_CONFIG_HOME', () => {
|
||||
mockFishFiles({ '/cfg/fish/config.fish': 'set -gx CODEX_HOME /from/xdg\n' })
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH, '/cfg')).toBe('/from/xdg')
|
||||
})
|
||||
|
||||
// Cover the env-default path too so fish cases cannot silently depend on host configuration.
|
||||
it('defaults configHome to XDG_CONFIG_HOME from the environment', () => {
|
||||
process.env.XDG_CONFIG_HOME = '/cfg'
|
||||
__resetShellStartupEnvCache()
|
||||
mockFishFiles({ '/cfg/fish/config.fish': 'set -gx CODEX_HOME /from/env-xdg\n' })
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/env-xdg')
|
||||
})
|
||||
|
||||
it('ignores sets that are not exported', () => {
|
||||
mockFishFiles({
|
||||
'/home/alice/.config/fish/config.fish':
|
||||
'set -g CODEX_HOME /global\nset -l CODEX_HOME /local\nset CODEX_HOME /plain\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts every fish export spelling', () => {
|
||||
for (const line of [
|
||||
'set -x CODEX_HOME /a',
|
||||
'set -xg CODEX_HOME /a',
|
||||
'set -gx CODEX_HOME /a',
|
||||
'set -Ux CODEX_HOME /a',
|
||||
'set --export --global CODEX_HOME /a'
|
||||
]) {
|
||||
__resetShellStartupEnvCache()
|
||||
mockFishFiles({ '/home/alice/.config/fish/config.fish': `${line}\n` })
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/a')
|
||||
}
|
||||
})
|
||||
|
||||
it('expands $HOME in double quotes and keeps single quotes literal', () => {
|
||||
mockFishFiles({
|
||||
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME "$HOME/.codex" # note\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/home/alice/.codex')
|
||||
|
||||
__resetShellStartupEnvCache()
|
||||
mockFishFiles({
|
||||
'/home/alice/.config/fish/config.fish': "set -gx CODEX_HOME '$HOME/.codex'\n"
|
||||
})
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('$HOME/.codex')
|
||||
})
|
||||
|
||||
it('does not read zsh or bash startup files for a fish user', () => {
|
||||
mockFishFiles({
|
||||
'/home/alice/.zshrc': 'export CODEX_HOME=/from/zsh\n',
|
||||
'/home/alice/.bash_profile': 'export CODEX_HOME=/from/bash\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores a commented assignment', () => {
|
||||
mockFishFiles({
|
||||
'/home/alice/.config/fish/config.fish': '# set -gx CODEX_HOME /from-comment\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not match a different variable with the same prefix', () => {
|
||||
mockFishFiles({
|
||||
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME_BACKUP /backup\n'
|
||||
})
|
||||
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// Session XDG_CONFIG_HOME must match the fish config path used locally and over relay.
|
||||
describe('readSessionShellStartupEnvVar', () => {
|
||||
const FISH = '/opt/homebrew/bin/fish'
|
||||
const savedConfigHome = process.env.XDG_CONFIG_HOME
|
||||
const savedHome = process.env.HOME
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('XDG_CONFIG_HOME', savedConfigHome)
|
||||
restoreEnv('HOME', savedHome)
|
||||
})
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
it("prefers the session env's XDG_CONFIG_HOME over the main process's", () => {
|
||||
process.env.XDG_CONFIG_HOME = '/main-process-cfg'
|
||||
mockStartupFiles({
|
||||
'/session-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/session\n',
|
||||
'/main-process-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/main-process\n'
|
||||
})
|
||||
|
||||
expect(
|
||||
readSessionShellStartupEnvVar('CODEX_HOME', {
|
||||
HOME: '/home/alice',
|
||||
SHELL: FISH,
|
||||
XDG_CONFIG_HOME: '/session-cfg'
|
||||
})
|
||||
).toBe('/from/session')
|
||||
})
|
||||
|
||||
it('falls back to the main process XDG_CONFIG_HOME when the session env lacks one', () => {
|
||||
process.env.XDG_CONFIG_HOME = '/main-process-cfg'
|
||||
mockStartupFiles({
|
||||
'/main-process-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/main-process\n'
|
||||
})
|
||||
|
||||
expect(
|
||||
readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH })
|
||||
).toBe('/from/main-process')
|
||||
})
|
||||
|
||||
it("falls back to fish's own ~/.config default when neither env has one", () => {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
mockStartupFiles({
|
||||
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/default\n'
|
||||
})
|
||||
|
||||
expect(
|
||||
readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH })
|
||||
).toBe('/from/default')
|
||||
})
|
||||
|
||||
it('resolves against the session HOME, not the main process HOME', () => {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
process.env.HOME = '/home/root-user'
|
||||
mockStartupFiles({
|
||||
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME "$HOME/.codex"\n',
|
||||
'/home/root-user/.config/fish/config.fish': 'set -gx CODEX_HOME /from/wrong-home\n'
|
||||
})
|
||||
|
||||
expect(
|
||||
readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH })
|
||||
).toBe('/home/alice/.codex')
|
||||
})
|
||||
|
||||
it('lets an explicit shell override beat the session SHELL', () => {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
mockStartupFiles({
|
||||
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/fish\n',
|
||||
'/home/alice/.zshrc': 'export CODEX_HOME=/from/zsh\n'
|
||||
})
|
||||
|
||||
expect(
|
||||
readSessionShellStartupEnvVar(
|
||||
'CODEX_HOME',
|
||||
{ HOME: '/home/alice', SHELL: '/bin/zsh' },
|
||||
FISH
|
||||
)
|
||||
).toBe('/from/fish')
|
||||
})
|
||||
|
||||
it('falls back to the main process HOME and SHELL with no session env at all', () => {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
process.env.HOME = '/home/alice'
|
||||
process.env.SHELL = FISH
|
||||
mockStartupFiles({
|
||||
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/process-env\n'
|
||||
})
|
||||
|
||||
expect(readSessionShellStartupEnvVar('CODEX_HOME', undefined)).toBe('/from/process-env')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,395 +0,0 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './posix-shell-startup-command'
|
||||
import { selectShellStartupFeatures } from './shell-startup-features'
|
||||
import { prependOrcaCliDirToChildPath } from '../../cli/orca-cli-child-path'
|
||||
import { runZshPty } from './zsh-startup-hook-pty-harness'
|
||||
import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates'
|
||||
import {
|
||||
importFreshLocalPtyShellReady,
|
||||
restoreUserDataPathAfterEach,
|
||||
setTestUserDataPath
|
||||
} from '../../providers/local-pty/local-pty-shell-ready-test-harness'
|
||||
|
||||
restoreUserDataPathAfterEach()
|
||||
|
||||
const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0
|
||||
const ZSH_PATH = hasZsh
|
||||
? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim()
|
||||
: ''
|
||||
const itWithZsh = hasZsh ? it : it.skip
|
||||
const describePosix = process.platform === 'win32' ? describe.skip : describe
|
||||
|
||||
const PLAIN_PANE = {
|
||||
hasStartupCommand: false,
|
||||
waitsForShellReady: false,
|
||||
emitsStartupIdentity: false
|
||||
}
|
||||
|
||||
describe('shell startup feature selection', () => {
|
||||
it('ignores an inherited ORCA_SHELL_FEATURES in the spawn env', () => {
|
||||
expect(
|
||||
selectShellStartupFeatures({
|
||||
shellPath: '/bin/zsh',
|
||||
env: { ORCA_SHELL_FEATURES: 'overlay,markers,ready,identity,history' },
|
||||
...PLAIN_PANE
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('cannot be turned off for a pane that needs a feature', () => {
|
||||
expect(
|
||||
selectShellStartupFeatures({
|
||||
shellPath: '/bin/zsh',
|
||||
env: { ORCA_SHELL_FEATURES: '', ORCA_HISTFILE: '/tmp/wt/zsh_history' },
|
||||
...PLAIN_PANE
|
||||
})
|
||||
).toEqual(['history'])
|
||||
})
|
||||
|
||||
it('gives a history-only pane no command markers', () => {
|
||||
expect(
|
||||
selectShellStartupFeatures({
|
||||
shellPath: '/bin/zsh',
|
||||
env: { ORCA_HISTFILE: '/tmp/wt/zsh_history' },
|
||||
...PLAIN_PANE
|
||||
})
|
||||
).not.toContain('markers')
|
||||
})
|
||||
|
||||
it('keeps markers on a pane that already had them', () => {
|
||||
expect(
|
||||
selectShellStartupFeatures({
|
||||
shellPath: '/bin/zsh',
|
||||
env: { ORCA_CODEX_HOME: '/tmp/codex' },
|
||||
...PLAIN_PANE
|
||||
})
|
||||
).toContain('markers')
|
||||
})
|
||||
|
||||
it('does not wrap bash for history alone', () => {
|
||||
expect(
|
||||
selectShellStartupFeatures({
|
||||
shellPath: '/bin/bash',
|
||||
env: { ORCA_HISTFILE: '/tmp/wt/bash_history' },
|
||||
...PLAIN_PANE
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describePosix('zsh launch config', () => {
|
||||
let userDataPath = ''
|
||||
let previousFeatures: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-feature-channel-'))
|
||||
setTestUserDataPath(userDataPath)
|
||||
previousFeatures = process.env.ORCA_SHELL_FEATURES
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousFeatures === undefined) {
|
||||
delete process.env.ORCA_SHELL_FEATURES
|
||||
} else {
|
||||
process.env.ORCA_SHELL_FEATURES = previousFeatures
|
||||
}
|
||||
chmodSync(userDataPath, 0o755)
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
itWithZsh.each([false, true])(
|
||||
'restores this app CLI after zsh startup resets PATH and replaces prompt hooks %s',
|
||||
async (replacePromptHooks) => {
|
||||
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)
|
||||
}
|
||||
writeFileSync(
|
||||
join(userDataPath, '.zshrc'),
|
||||
`export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${replacePromptHooks ? 'precmd_functions=()\n' : ''}`
|
||||
)
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
HOME: userDataPath,
|
||||
USERPROFILE: userDataPath,
|
||||
PATH: `${ambientBin}:/usr/bin:/bin`
|
||||
}
|
||||
const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath })
|
||||
const features = selectShellStartupFeatures({
|
||||
shellPath: ZSH_PATH,
|
||||
env,
|
||||
...PLAIN_PANE,
|
||||
hasStartupCommand: true
|
||||
})
|
||||
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
const config = getShellLaunchConfig(ZSH_PATH, features)
|
||||
const result = await runZshPty({
|
||||
env: { ...env, ...config.env },
|
||||
commands: ['ORCA_LOOKUP=$(command -v orca-dev)'],
|
||||
report: ['ORCA_LOOKUP']
|
||||
})
|
||||
expect(result.values.ORCA_LOOKUP).toBe(launcher)
|
||||
}
|
||||
)
|
||||
|
||||
it('publishes exactly the selected features, whatever process.env holds', async () => {
|
||||
process.env.ORCA_SHELL_FEATURES = 'overlay,markers,ready,identity'
|
||||
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
const config = getShellLaunchConfig('/bin/zsh', ['history'])
|
||||
|
||||
expect(config.env.ORCA_SHELL_FEATURES).toBe('history')
|
||||
})
|
||||
|
||||
it('wraps a startup-only command without enabling shell readiness', async () => {
|
||||
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
const config = getShellLaunchConfig('/bin/zsh', [], 'codex')
|
||||
|
||||
expect(config.env.ORCA_SHELL_FEATURES).toBe('startup')
|
||||
expect(config.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe('codex')
|
||||
expect(config.supportsReadyMarker).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to plain login zsh when the wrapper tree cannot be written', async () => {
|
||||
chmodSync(userDataPath, 0o500)
|
||||
if (
|
||||
spawnSync('sh', ['-c', `touch ${JSON.stringify(join(userDataPath, 'probe'))}`]).status === 0
|
||||
) {
|
||||
return // running with write access regardless of mode (e.g. root)
|
||||
}
|
||||
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
const config = getShellLaunchConfig('/bin/zsh', ['history'])
|
||||
|
||||
expect(config).toEqual({
|
||||
args: ['-l'],
|
||||
env: {},
|
||||
supportsReadyMarker: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat another terminal’s hijacked ZDOTDIR as the user config dir', async () => {
|
||||
// Other terminals may own ZDOTDIR; reject only positively identified Orca or empty directories.
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-stacked-zdotdir-'))
|
||||
const foreignWrapper = join(home, 'other-terminal', 'zsh')
|
||||
mkdirSync(foreignWrapper, { recursive: true })
|
||||
const previousZdotdir = process.env.ZDOTDIR
|
||||
const previousHome = process.env.HOME
|
||||
process.env.ZDOTDIR = foreignWrapper
|
||||
process.env.HOME = home
|
||||
try {
|
||||
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
// An absent original ZDOTDIR must stay absent, rather than becoming HOME.
|
||||
expect(getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBeUndefined()
|
||||
|
||||
// The marker identifies ownership even without an Orca-shaped path.
|
||||
writeFileSync(join(foreignWrapper, '.zshrc'), '')
|
||||
writeFileSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE), '')
|
||||
const stamped = await importFreshLocalPtyShellReady()
|
||||
expect(
|
||||
stamped.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR
|
||||
).toBeUndefined()
|
||||
|
||||
rmSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE))
|
||||
const real = await importFreshLocalPtyShellReady()
|
||||
expect(real.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBe(
|
||||
foreignWrapper
|
||||
)
|
||||
} finally {
|
||||
if (previousZdotdir === undefined) {
|
||||
delete process.env.ZDOTDIR
|
||||
} else {
|
||||
process.env.ZDOTDIR = previousZdotdir
|
||||
}
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = previousHome
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describePosix('epilogue under hostile user shell options', () => {
|
||||
let userDataPath = ''
|
||||
let home = ''
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-feature-channel-options-'))
|
||||
setTestUserDataPath(userDataPath)
|
||||
home = mkdtempSync(join(tmpdir(), 'orca-feature-options-home-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// User options affect the deferred hook: NO_UNSET rejects absent variables; KSH_ARRAYS is zero-based.
|
||||
it.each(['no_unset', 'ksh_arrays'])(
|
||||
'still runs every feature when the user set %s',
|
||||
async (option) => {
|
||||
if (!hasZsh) {
|
||||
return
|
||||
}
|
||||
const scoped = join(home, 'orca-history', 'zsh_history')
|
||||
const opencodeDir = join(home, 'opencode-overlay')
|
||||
writeFileSync(join(home, '.zshrc'), `setopt ${option}\n`)
|
||||
// overlay is the first token, exposing KSH_ARRAYS subscript errors.
|
||||
const spawnEnv: Record<string, string> = {
|
||||
HOME: home,
|
||||
ORCA_HISTFILE: scoped,
|
||||
ORCA_OPENCODE_CONFIG_DIR: opencodeDir
|
||||
}
|
||||
const features = selectShellStartupFeatures({
|
||||
shellPath: ZSH_PATH,
|
||||
env: spawnEnv,
|
||||
hasStartupCommand: true,
|
||||
waitsForShellReady: true,
|
||||
emitsStartupIdentity: true
|
||||
})
|
||||
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
const launch = getShellLaunchConfig(ZSH_PATH, features)
|
||||
|
||||
// A -c shell never reaches precmd; exercise the hook through a PTY.
|
||||
const { values } = await runZshPty({
|
||||
env: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
...spawnEnv,
|
||||
...launch.env,
|
||||
ORCA_ORIG_ZDOTDIR: home
|
||||
},
|
||||
report: ['LINEINIT', 'PRECMD', 'OPENCODE_CONFIG_DIR', 'ZDOTDIR', 'HISTFILE'],
|
||||
commands: [
|
||||
'LINEINIT="${widgets[zle-line-init]:-none}"; PRECMD="${precmd_functions[*]:-none}"'
|
||||
]
|
||||
})
|
||||
|
||||
expect(values.LINEINIT).toBe('user:__orca_prompt_mark')
|
||||
expect(values.PRECMD).toContain('__orca_osc133_precmd')
|
||||
expect(values.OPENCODE_CONFIG_DIR).toBe(opencodeDir)
|
||||
expect(values.ZDOTDIR).toBe(home)
|
||||
expect(values.HISTFILE).toBe(scoped)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function makeUserHome(): string {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-feature-home-'))
|
||||
for (const file of ['.zshenv', '.zprofile', '.zshrc', '.zlogin']) {
|
||||
writeFileSync(join(home, file), `print -r -- "RAN=${file}"\n`)
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
describePosix('history-only pane in a real zsh', () => {
|
||||
let userDataPath = ''
|
||||
let home = ''
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-feature-channel-live-'))
|
||||
setTestUserDataPath(userDataPath)
|
||||
home = makeUserHome()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function withoutInheritedZdotdir(env: Record<string, string>): Record<string, string> {
|
||||
const { ORCA_ORIG_ZDOTDIR: _inherited, ...rest } = env
|
||||
return rest
|
||||
}
|
||||
|
||||
async function launchHistoryOnly(): Promise<{
|
||||
args: string[]
|
||||
env: Record<string, string>
|
||||
zdotdir: string
|
||||
}> {
|
||||
const scoped = join(home, 'orca-history', 'zsh_history')
|
||||
const spawnEnv: Record<string, string> = {
|
||||
HOME: home,
|
||||
HISTFILE: scoped,
|
||||
ORCA_HISTFILE: scoped
|
||||
}
|
||||
const features = selectShellStartupFeatures({
|
||||
shellPath: ZSH_PATH,
|
||||
env: spawnEnv,
|
||||
...PLAIN_PANE
|
||||
})
|
||||
expect(features).toEqual(['history'])
|
||||
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
const launch = getShellLaunchConfig(ZSH_PATH, features)
|
||||
return {
|
||||
args: launch.args ?? ['-l'],
|
||||
env: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
...spawnEnv,
|
||||
// This test home has no ZDOTDIR; do not inherit the developer's config.
|
||||
...withoutInheritedZdotdir(launch.env)
|
||||
},
|
||||
zdotdir: launch.env.ZDOTDIR
|
||||
}
|
||||
}
|
||||
|
||||
itWithZsh('unsets the feature channel for the shell and for a grandchild', async () => {
|
||||
const { args, env } = await launchHistoryOnly()
|
||||
|
||||
const output = execFileSync(
|
||||
ZSH_PATH,
|
||||
[
|
||||
...args,
|
||||
'-i',
|
||||
'-c',
|
||||
`print -r -- "SELF=[\${ORCA_SHELL_FEATURES:-}]"; zsh -c 'printenv ORCA_SHELL_FEATURES; print -r -- CHILD_CLEAN'`
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 20_000, env }
|
||||
)
|
||||
|
||||
expect(output).toContain('SELF=[]')
|
||||
expect(output).toContain('CHILD_CLEAN')
|
||||
expect(output).not.toContain('history')
|
||||
})
|
||||
|
||||
itWithZsh(
|
||||
'emits no OSC 133 and leaves a pane observably identical to an unwrapped one',
|
||||
async () => {
|
||||
// The deferred hook runs at the first prompt, so -c would inspect unfinished setup.
|
||||
const { env } = await launchHistoryOnly()
|
||||
const capture = [
|
||||
'PRECMD="${precmd_functions[*]}"; PREEXEC="${preexec_functions[*]}"',
|
||||
'LINEINIT="${widgets[zle-line-init]:-none}"'
|
||||
]
|
||||
const report = ['PRECMD', 'PREEXEC', 'LINEINIT', 'ZDOTDIR', 'ORCA_SHELL_FEATURES']
|
||||
|
||||
const wrapped = await runZshPty({ env, commands: capture, report })
|
||||
const unwrapped = await runZshPty({
|
||||
env: { PATH: '/usr/bin:/bin', HOME: home },
|
||||
commands: capture,
|
||||
report
|
||||
})
|
||||
|
||||
expect(wrapped.output).not.toContain('\x1b]133;')
|
||||
expect(wrapped.values.PRECMD).toBe(unwrapped.values.PRECMD)
|
||||
expect(wrapped.values.PREEXEC).toBe(unwrapped.values.PREEXEC)
|
||||
// Global zsh config may install a widget; compare against the same host's unwrapped shell.
|
||||
expect(wrapped.values.LINEINIT).toBe(unwrapped.values.LINEINIT)
|
||||
expect(wrapped.values.PRECMD).not.toContain('orca')
|
||||
expect(wrapped.values.ZDOTDIR).toBe(unwrapped.values.ZDOTDIR)
|
||||
expect(wrapped.values.ORCA_SHELL_FEATURES).toBe('UNSET')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,305 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { getShellReadyWrapperRoot } from '../../providers/local-pty/local-pty-shell-ready-wrapper-root'
|
||||
import {
|
||||
SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV,
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV,
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV
|
||||
} from '../../../shared/setup-agent-sequencing'
|
||||
import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from './wsl-orca-env'
|
||||
import { CODEX_REMOTE_VIEWER_SSH_CONNECTION } from '../../../shared/remote-viewer-clipboard-env'
|
||||
|
||||
describe('addOrcaWslInteropEnv', () => {
|
||||
it.each(['1', '0'])(
|
||||
'forwards Claude image override %s to WSL without path translation',
|
||||
(value) => {
|
||||
const env: Record<string, string> = { CLAUDE_CODE_FORCE_TERMINAL_IMAGES: value }
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
addOrcaWslInteropEnv(env)
|
||||
|
||||
expect(env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES).toBe(value)
|
||||
expect(
|
||||
env.WSLENV.split(':').filter((entry) =>
|
||||
entry.startsWith('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')
|
||||
)
|
||||
).toEqual(['CLAUDE_CODE_FORCE_TERMINAL_IMAGES/u'])
|
||||
}
|
||||
)
|
||||
|
||||
it('marks the Orca terminal handle for Windows to WSL env import', () => {
|
||||
const env: Record<string, string> = { ORCA_TERMINAL_HANDLE: 'term_wsl' }
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
|
||||
expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p')
|
||||
})
|
||||
|
||||
// Guests cannot derive the wrapper content hash from userData; publish the resolved root to avoid unwrapped launches.
|
||||
it('publishes the resolved wrapper root path-translated for the guest', () => {
|
||||
const env: Record<string, string> = {}
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
|
||||
expect(env.ORCA_SHELL_READY_ROOT).toBe(getShellReadyWrapperRoot())
|
||||
expect(isAbsolute(env.ORCA_SHELL_READY_ROOT as string)).toBe(true)
|
||||
// Translate the host wrapper path for guest access.
|
||||
expect(env.WSLENV?.split(':')).toContain('ORCA_SHELL_READY_ROOT/p')
|
||||
})
|
||||
|
||||
it('imports setup-gated startup and setup env into WSL without path translation', () => {
|
||||
const env: Record<string, string> = {
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'codex',
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: 'while :; do sleep 1; done',
|
||||
[SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]: '( bash /repo/setup-runner.sh )'
|
||||
}
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
|
||||
expect(env.WSLENV?.split(':')).toEqual([
|
||||
'ORCA_SHELL_READY_ROOT/p',
|
||||
`${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}/u`,
|
||||
`${SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV}/u`,
|
||||
`${SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV}/u`
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves existing WSLENV entries and does not duplicate the handle entry', () => {
|
||||
const env: Record<string, string> = {
|
||||
WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p'
|
||||
}
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
|
||||
expect(env.WSLENV).toBe('FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p:ORCA_SHELL_READY_ROOT/p')
|
||||
})
|
||||
|
||||
it('marks OMP status and hook env for Windows to WSL import', () => {
|
||||
const env: Record<string, string> = {
|
||||
ORCA_TERMINAL_HANDLE: 'term_wsl',
|
||||
ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca',
|
||||
ORCA_CLI_COMMAND: 'orca-ide',
|
||||
ORCA_WSL_CLI_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\wsl-managed-cli\\hash',
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe',
|
||||
ORCA_CODEX_ISOLATE: '0',
|
||||
ORCA_OMP_FRESH_CONFIG: 'C:\\Orca\\fresh-session.yml',
|
||||
ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts',
|
||||
ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts',
|
||||
ORCA_PANE_KEY: 'tab-1:leaf-1',
|
||||
ORCA_AGENT_PANE: 'tab-1:leaf-1',
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret',
|
||||
ORCA_AGENT_LAUNCH: 'launch-secret',
|
||||
ORCA_OPENCODE_AGENT: 'opencode2',
|
||||
ORCA_AGENT_HOOK_PORT: '4567',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_AGENT_HOOK_ENV: 'dev',
|
||||
ORCA_AGENT_HOOK_VERSION: '1',
|
||||
ORCA_AGENT_HOOK_TRANSPORT: 'raw-json-v1',
|
||||
ORCA_WSL_HOOK_INSTANCE: 'testinstance',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu',
|
||||
ORCA_RUNTIME_SOURCE_ID: '11111111-1111-4111-8111-111111111111',
|
||||
ORCA_RUNTIME_SOURCE_INCARNATION: 'runtime-a',
|
||||
ORCA_RUNTIME_SOURCE_PROFILE_PATH: 'C:\\Users\\a\\AppData\\Roaming\\orca'
|
||||
}
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
|
||||
expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u')
|
||||
expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p')
|
||||
expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u')
|
||||
expect(env.WSLENV).toContain('ORCA_WSL_CLI_DIR/p')
|
||||
expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p')
|
||||
expect(env.WSLENV).toContain('ORCA_CODEX_ISOLATE/u')
|
||||
expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p')
|
||||
expect(env.WSLENV).toContain('ORCA_OMP_FRESH_CONFIG/p')
|
||||
expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION')
|
||||
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_PANE/u')
|
||||
expect(env.WSLENV).toContain('ORCA_TAB_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_LAUNCH_TOKEN/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_LAUNCH/u')
|
||||
expect(env.WSLENV).toContain('ORCA_OPENCODE_AGENT/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_PORT/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TRANSPORT/u')
|
||||
expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u')
|
||||
expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_INCARNATION/u')
|
||||
expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_PROFILE_PATH/u')
|
||||
})
|
||||
|
||||
it('overwrites caller host evidence with native runtime WSL authority', () => {
|
||||
const env = {
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'caller-host',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'caller-incarnation',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'caller-attachment'
|
||||
}
|
||||
|
||||
stampWslOrchestrationCompatibilityHost(env, 'local', 'Ubuntu')
|
||||
|
||||
expect(env).toEqual({
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu'
|
||||
})
|
||||
})
|
||||
|
||||
it('clears inherited host evidence outside a runtime-owned WSL scope', () => {
|
||||
const env = {
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'caller-host',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'caller-incarnation',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'caller-attachment'
|
||||
}
|
||||
|
||||
stampWslOrchestrationCompatibilityHost(env, 'local', null)
|
||||
|
||||
expect(env).toEqual({})
|
||||
})
|
||||
|
||||
it('path-translates a Windows hook endpoint but passes a guest-side one untouched', () => {
|
||||
const windowsEnv: Record<string, string> = {
|
||||
ORCA_AGENT_HOOK_ENDPOINT: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\agent-hooks\\endpoint.cmd'
|
||||
}
|
||||
addOrcaWslInteropEnv(windowsEnv)
|
||||
expect(windowsEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/p')
|
||||
|
||||
const guestEnv: Record<string, string> = {
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '/home/jin/.orca-wsl/agent-hooks/port-4567/endpoint.env'
|
||||
}
|
||||
addOrcaWslInteropEnv(guestEnv)
|
||||
expect(guestEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/u')
|
||||
expect(guestEnv.WSLENV).not.toContain('ORCA_AGENT_HOOK_ENDPOINT/p')
|
||||
})
|
||||
|
||||
it('tags pre-translated Linux setup paths /u so WSLENV does not translate them again (#9206)', () => {
|
||||
const env: Record<string, string> = {
|
||||
ORCA_ROOT_PATH: '/home/jin/repo',
|
||||
ORCA_WORKTREE_PATH: '/home/jin/repo-worktrees/fix-1',
|
||||
ORCA_WORKSPACE_NAME: 'fix-1',
|
||||
CONDUCTOR_ROOT_PATH: '/home/jin/repo',
|
||||
GHOSTX_ROOT_PATH: '/home/jin/repo'
|
||||
}
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
|
||||
// Already-Linux hook paths must cross untranslated to avoid a second conversion.
|
||||
expect(env.WSLENV).toContain('ORCA_ROOT_PATH/u')
|
||||
expect(env.WSLENV).toContain('ORCA_WORKTREE_PATH/u')
|
||||
expect(env.WSLENV).toContain('CONDUCTOR_ROOT_PATH/u')
|
||||
expect(env.WSLENV).toContain('GHOSTX_ROOT_PATH/u')
|
||||
expect(env.WSLENV).not.toContain('ORCA_ROOT_PATH/p')
|
||||
expect(env.WSLENV).not.toContain('ORCA_WORKTREE_PATH/p')
|
||||
expect(env.ORCA_ROOT_PATH).toBe('/home/jin/repo')
|
||||
expect(env.ORCA_WORKTREE_PATH).toBe('/home/jin/repo-worktrees/fix-1')
|
||||
})
|
||||
|
||||
it('tags untranslated Windows setup paths /p so WSLENV translates them (wsl.exe shell over a Windows worktree)', () => {
|
||||
const env: Record<string, string> = {
|
||||
ORCA_ROOT_PATH: 'C:\\Users\\jin\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\Users\\jin\\repo-worktrees\\fix-1',
|
||||
CONDUCTOR_ROOT_PATH: 'C:\\Users\\jin\\repo',
|
||||
GHOSTX_ROOT_PATH: 'C:\\Users\\jin\\repo'
|
||||
}
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
|
||||
expect(env.WSLENV).toContain('ORCA_ROOT_PATH/p')
|
||||
expect(env.WSLENV).toContain('ORCA_WORKTREE_PATH/p')
|
||||
expect(env.WSLENV).toContain('CONDUCTOR_ROOT_PATH/p')
|
||||
expect(env.WSLENV).toContain('GHOSTX_ROOT_PATH/p')
|
||||
expect(env.WSLENV).not.toContain('ORCA_ROOT_PATH/u')
|
||||
expect(env.WSLENV).not.toContain('ORCA_WORKTREE_PATH/u')
|
||||
})
|
||||
|
||||
it('always tags ORCA_WORKSPACE_NAME /u because it is a name, not a path', () => {
|
||||
const env: Record<string, string> = { ORCA_WORKSPACE_NAME: 'fix-1' }
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
|
||||
expect(env.WSLENV).toBe('ORCA_SHELL_READY_ROOT/p:ORCA_WORKSPACE_NAME/u')
|
||||
})
|
||||
|
||||
it.each(['repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', 'folder:f1'])(
|
||||
'crosses the workspace id %s into the guest untranslated (/u), as the window and main stamp it',
|
||||
(workspaceId) => {
|
||||
const env: Record<string, string> = { ORCA_WORKSPACE_ID: workspaceId }
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
|
||||
expect(env.ORCA_WORKSPACE_ID).toBe(workspaceId)
|
||||
expect(env.WSLENV?.split(':')).toContain('ORCA_WORKSPACE_ID/u')
|
||||
}
|
||||
)
|
||||
|
||||
// #10861: wsl.exe drops SSH_CONNECTION, so WSL Codex copied only to the Windows clipboard.
|
||||
it('crosses the Codex remote-viewer clipboard marker into the guest untranslated (/u)', () => {
|
||||
const env: Record<string, string> = {
|
||||
SSH_CONNECTION: CODEX_REMOTE_VIEWER_SSH_CONNECTION,
|
||||
ORCA_REMOTE_VIEWER_CLIPBOARD: '1'
|
||||
}
|
||||
addOrcaWslInteropEnv(env)
|
||||
expect(env.WSLENV?.split(':')).toContain('SSH_CONNECTION/u')
|
||||
expect(env.WSLENV?.split(':')).toContain('ORCA_REMOTE_VIEWER_CLIPBOARD/u')
|
||||
})
|
||||
|
||||
it("leaves the Windows host's own SSH session out of the guest", () => {
|
||||
const env: Record<string, string> = { SSH_CONNECTION: '10.0.0.1 5000 10.0.0.2 22' }
|
||||
addOrcaWslInteropEnv(env)
|
||||
expect(env.WSLENV?.split(':')).not.toContain('SSH_CONNECTION/u')
|
||||
})
|
||||
|
||||
it('crosses the inline-image protocol hint into the guest untranslated (/u)', () => {
|
||||
const env: Record<string, string> = { ORCA_IMAGE_PROTOCOL: 'kitty' }
|
||||
addOrcaWslInteropEnv(env)
|
||||
expect(env.WSLENV).toContain('ORCA_IMAGE_PROTOCOL/u')
|
||||
})
|
||||
|
||||
it('marks the WSL hook relay version for import on relay spawn envs', () => {
|
||||
const env: Record<string, string> = {
|
||||
ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc'
|
||||
}
|
||||
addOrcaWslInteropEnv(env)
|
||||
expect(env.WSLENV).toBe('ORCA_SHELL_READY_ROOT/p:ORCA_WSL_HOOK_RELAY_VERSION/u')
|
||||
})
|
||||
|
||||
it('crosses a guest-side OpenCode config overlay untranslated (/u)', () => {
|
||||
const env: Record<string, string> = {
|
||||
OPENCODE_CONFIG_DIR: '/home/jin/.orca-relay/opencode-overlays/abc',
|
||||
ORCA_OPENCODE_CONFIG_DIR: '/home/jin/.orca-relay/opencode-overlays/abc'
|
||||
}
|
||||
addOrcaWslInteropEnv(env)
|
||||
expect(env.WSLENV).toContain('OPENCODE_CONFIG_DIR/u')
|
||||
expect(env.WSLENV).toContain('ORCA_OPENCODE_CONFIG_DIR/u')
|
||||
expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR/p')
|
||||
})
|
||||
|
||||
it('never crosses a Windows OpenCode config dir into the guest', () => {
|
||||
// Windows overlays can leak through inherited relay/daemon env; never translate them into guest OpenCode config roots.
|
||||
const env: Record<string, string> = {
|
||||
OPENCODE_CONFIG_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\opencode-overlays\\abc',
|
||||
ORCA_OPENCODE_CONFIG_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\opencode-overlays\\abc'
|
||||
}
|
||||
addOrcaWslInteropEnv(env)
|
||||
expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR')
|
||||
expect(env.WSLENV).not.toContain('ORCA_OPENCODE_CONFIG_DIR')
|
||||
})
|
||||
|
||||
it('does not register the OpenCode config vars when they are absent', () => {
|
||||
const env: Record<string, string> = { ORCA_TERMINAL_HANDLE: 'term_wsl' }
|
||||
addOrcaWslInteropEnv(env)
|
||||
expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR')
|
||||
expect(env.WSLENV).not.toContain('ORCA_OPENCODE_CONFIG_DIR')
|
||||
})
|
||||
})
|
||||
@@ -1,344 +0,0 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { prependOrcaCliDirToChildPath } from '../../cli/orca-cli-child-path'
|
||||
import { getDaemonZshWrapperSpec } from '../../daemon/shell-readiness/daemon-zsh-shell-ready-wrapper-spec'
|
||||
import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './posix-shell-startup-command'
|
||||
import { getZshShellReadyWrapperFile } from '../../providers/local-pty/local-pty-shell-ready-wrapper-generation'
|
||||
import { encodeShellStartupFeatures, selectShellStartupFeatures } from './shell-startup-features'
|
||||
import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates'
|
||||
import { buildZshStartupHook } from './zsh-startup-wrapper-builder'
|
||||
import { hasZsh, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness'
|
||||
|
||||
const itWithZsh = hasZsh ? it : it.skip
|
||||
const USER_WIDGET = `orca_test_line_init() {
|
||||
O_UC=$((\${O_UC:-0}+1))
|
||||
O_UN="$WIDGET"
|
||||
builtin printf 'ORCA_TEST_USER_WIDGET_CALL\\n'
|
||||
return 1
|
||||
}
|
||||
zle -N zle-line-init orca_test_line_init
|
||||
`
|
||||
const USER_REDRAW = `orca_test_redraw() {
|
||||
O_RC=$((\${O_RC:-0}+1))
|
||||
O_RN="$WIDGET"
|
||||
return 1
|
||||
}
|
||||
zle -N zle-line-pre-redraw orca_test_redraw
|
||||
`
|
||||
const USER_PRECMD = `precmd() {
|
||||
O_PCALLS=$((\${O_PCALLS:-0}+1))
|
||||
O_PN="$0"
|
||||
O_PO="\${O_PO:-\${options[ksharrays]}:\${options[nounset]}}"
|
||||
return 1
|
||||
}
|
||||
`
|
||||
|
||||
describe('zsh deferred startup after prompt-hook replacement', () => {
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
itWithZsh.each([
|
||||
'history',
|
||||
'startup',
|
||||
'chained-startup',
|
||||
'stock-history',
|
||||
'stock-startup',
|
||||
'stock-chained-redraw',
|
||||
'stock-nonzero-precmd',
|
||||
'ordered-startup',
|
||||
'replaced-precmd-startup',
|
||||
'status-startup',
|
||||
'sticky-startup',
|
||||
'scheduled-startup',
|
||||
'unavailable-startup',
|
||||
'repeat-history',
|
||||
'repeat-startup'
|
||||
] as const)(
|
||||
'restores CLI precedence and preserves the user widget in a %s pane',
|
||||
async (intent) => {
|
||||
const historyOnly = intent.endsWith('history')
|
||||
const repeatSource = intent.startsWith('repeat-')
|
||||
const stockBinding = intent.startsWith('stock-')
|
||||
const chainedLineInit = intent === 'chained-startup'
|
||||
const chainedRedraw = intent === 'stock-chained-redraw'
|
||||
const nonzeroPrecmd = intent === 'stock-nonzero-precmd'
|
||||
const orderedPrecmd = intent === 'ordered-startup'
|
||||
const replacedPrecmd = intent === 'replaced-precmd-startup'
|
||||
const statusPrecmd = intent === 'status-startup'
|
||||
const stickyPrecmd = intent === 'sticky-startup'
|
||||
const scheduledPrecmd = intent === 'scheduled-startup'
|
||||
const unavailableSched = intent === 'unavailable-startup'
|
||||
const scheduleCleanup = scheduledPrecmd || unavailableSched || orderedPrecmd
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-deferred-line-init-'))
|
||||
roots.push(home)
|
||||
const cliBin = join(home, 'cli', 'bin')
|
||||
const ambientBin = join(home, 'ambient-bin')
|
||||
const wrapperDir = join(home, 'wrapper')
|
||||
for (const bin of [cliBin, ambientBin, wrapperDir]) {
|
||||
mkdirSync(bin, { recursive: true })
|
||||
}
|
||||
for (const bin of [cliBin, ambientBin]) {
|
||||
writeFileSync(join(bin, 'orca-dev'), '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(join(bin, 'orca-dev'), 0o755)
|
||||
}
|
||||
writeFileSync(
|
||||
join(home, '.zshenv'),
|
||||
(chainedLineInit || chainedRedraw || repeatSource
|
||||
? ''
|
||||
: stockBinding
|
||||
? USER_REDRAW
|
||||
: USER_WIDGET) +
|
||||
(statusPrecmd
|
||||
? 'precmd() { O_FIRST_IN=${O_FIRST_IN:-$?}; }\n'
|
||||
: stickyPrecmd
|
||||
? `emulate sh -c 'precmd() { O_FIRST_IN="\${O_FIRST_IN:-$?:\${options[shwordsplit]}:\${options[ksharrays]}}"; return 1; }'\n`
|
||||
: scheduledPrecmd
|
||||
? 'zmodload zsh/sched\nO_EVENT() { O_EO=${_orca_deferred_init_done:-0}; }\nsched +0 O_EVENT\nsched +3600 O_EVENT\n'
|
||||
: unavailableSched
|
||||
? 'zmodload zsh/zleparameter zsh/terminfo\nO_MP=("${module_path[@]}"); module_path=()\n'
|
||||
: nonzeroPrecmd || replacedPrecmd
|
||||
? USER_PRECMD
|
||||
: orderedPrecmd
|
||||
? USER_PRECMD.replace('return 1', 'return 0')
|
||||
: '')
|
||||
)
|
||||
if (statusPrecmd || stickyPrecmd) {
|
||||
writeFileSync(
|
||||
join(home, '.zlogin'),
|
||||
'orca_test_status() { return 42; }; orca_test_status\n'
|
||||
)
|
||||
}
|
||||
const stockWidget =
|
||||
stockBinding || stickyPrecmd || scheduledPrecmd
|
||||
? USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init')
|
||||
: ''
|
||||
writeFileSync(
|
||||
join(home, '.zshrc'),
|
||||
`${statusPrecmd || stickyPrecmd ? 'PS1="ORCA_FIRST_PROMPT:%? "\n' : ''}${repeatSource ? USER_WIDGET : ''}${unavailableSched ? 'module_path=("${O_MP[@]}")\n' : ''}${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${
|
||||
chainedLineInit
|
||||
? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n`
|
||||
: chainedRedraw
|
||||
? `${USER_REDRAW.replace('zle -N zle-line-pre-redraw orca_test_redraw', 'zle -N orca_test_redraw')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-pre-redraw orca_test_redraw\n`
|
||||
: ''
|
||||
}${nonzeroPrecmd ? 'orca_test_array() { O_AC=called; }\nprecmd_functions=(orca_test_array)\nsetopt KSH_ARRAYS NO_UNSET\n' : orderedPrecmd ? 'orca_test_array() { O_AO=${O_AO:-${_orca_deferred_init_done:-0}}; }\nprecmd_functions=(orca_test_array "${precmd_functions[@]}")\n' : replacedPrecmd ? 'precmd() { O_NPC=$((${O_NPC:-0}+1)); O_NPN="$0"; }\n' : ''}`
|
||||
)
|
||||
writeFileSync(join(wrapperDir, '.zshenv'), getZshShellReadyWrapperFile())
|
||||
writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '')
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
PATH: `${ambientBin}:/usr/bin:/bin`,
|
||||
DEBIAN_PREVENT_KEYBOARD_CHANGES: '1',
|
||||
ZDOTDIR: wrapperDir,
|
||||
ORCA_HISTFILE: join(home, 'scoped-history')
|
||||
}
|
||||
const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath: home })
|
||||
const features = selectShellStartupFeatures({
|
||||
shellPath: ZSH_PATH,
|
||||
env,
|
||||
hasStartupCommand: !historyOnly,
|
||||
waitsForShellReady: !historyOnly,
|
||||
emitsStartupIdentity: false
|
||||
})
|
||||
env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(features)
|
||||
if (!historyOnly) {
|
||||
env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'O_SU=$((${O_SU:-0}+1))'
|
||||
}
|
||||
|
||||
if (statusPrecmd || stickyPrecmd) {
|
||||
const baseline = await runZshPty({ env: { ...env, ZDOTDIR: home }, report: ['O_FIRST_IN'] })
|
||||
expect(baseline.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42')
|
||||
}
|
||||
|
||||
const result = await runZshPty({
|
||||
env,
|
||||
commands: [
|
||||
...(repeatSource
|
||||
? ['source -- "$HOME/wrapper/.zshenv"', 'source -- "$HOME/wrapper/.zshenv"']
|
||||
: []),
|
||||
'O_LK=$(command -v orca-dev)',
|
||||
'O_SR=${+widgets[__orca_saved_line_init]}',
|
||||
...(scheduleCleanup
|
||||
? [
|
||||
'O_SC=${+functions[__orca_deferred_sched_init]}',
|
||||
'O_SE=${zsh_scheduled_events[*]:-UNSET}'
|
||||
]
|
||||
: []),
|
||||
...(scheduledPrecmd ? ['O_EV=${#zsh_scheduled_events}'] : []),
|
||||
...(stockBinding ? ['O_RW=${widgets[zle-line-pre-redraw]:-none}'] : []),
|
||||
'O_LI=${widgets[zle-line-init]:-none}',
|
||||
'O_PC="${precmd_functions[*]}"',
|
||||
...(nonzeroPrecmd ? ['precmd; O_PS=$?'] : [])
|
||||
],
|
||||
report: [
|
||||
'O_LK',
|
||||
'O_UC',
|
||||
'O_UN',
|
||||
'O_SR',
|
||||
...(scheduleCleanup ? ['O_SC', 'O_SE'] : []),
|
||||
...(scheduledPrecmd ? ['O_EV', 'O_EO'] : []),
|
||||
...(stockBinding ? ['O_RW', 'O_RC', 'O_RN'] : []),
|
||||
...(nonzeroPrecmd ? ['O_PCALLS', 'O_PN', 'O_PO', 'O_AC', 'O_PS'] : []),
|
||||
...(orderedPrecmd ? ['O_PCALLS', 'O_PN', 'O_AO'] : []),
|
||||
...(replacedPrecmd ? ['O_PCALLS', 'O_NPC', 'O_NPN'] : []),
|
||||
...(statusPrecmd || stickyPrecmd ? ['O_FIRST_IN'] : []),
|
||||
'O_LI',
|
||||
'O_PC',
|
||||
'O_SU',
|
||||
'HISTFILE'
|
||||
]
|
||||
})
|
||||
|
||||
expect(result.values.O_LK).toBe(launcher)
|
||||
expect(Number(result.values.O_UC)).toBeGreaterThan(0)
|
||||
if (!chainedLineInit) {
|
||||
expect(result.values.O_UN).toBe('zle-line-init')
|
||||
}
|
||||
expect(result.values.O_SR).toBe('0')
|
||||
if (repeatSource) {
|
||||
expect(result.output).not.toContain('job table full or recursion limit exceeded')
|
||||
expect(result.values.O_PC).not.toContain('__orca_deferred_init')
|
||||
}
|
||||
if (scheduleCleanup) {
|
||||
expect(result.values.O_SC).toBe('0')
|
||||
expect(result.values.O_SE).not.toContain('orca')
|
||||
}
|
||||
if (unavailableSched) {
|
||||
// Stock completion modules can fail before the fixture restores module_path.
|
||||
expect(result.output).not.toContain('zsh/sched')
|
||||
expect(result.output).not.toContain('__orca_arm_deferred_line_init:')
|
||||
expect(result.values.O_SE).toBe('UNSET')
|
||||
}
|
||||
if (scheduledPrecmd) {
|
||||
expect(result.values.O_EV).toBe('1')
|
||||
expect(result.values.O_EO).toBe('0')
|
||||
expect(result.values.O_SE).toContain('O_EVENT')
|
||||
}
|
||||
if (stockBinding) {
|
||||
expect(Number(result.values.O_RC)).toBeGreaterThan(0)
|
||||
expect(result.values.O_RN).toBe(chainedRedraw ? 'orca_test_redraw' : 'zle-line-pre-redraw')
|
||||
if (!chainedRedraw) {
|
||||
expect(result.values.O_RW).toBe('user:orca_test_redraw')
|
||||
}
|
||||
}
|
||||
expect(result.values.HISTFILE).toBe(join(home, 'scoped-history'))
|
||||
if (nonzeroPrecmd) {
|
||||
expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0)
|
||||
expect(result.values.O_PN).toBe('precmd')
|
||||
expect(result.values.O_PO).toBe('on:on')
|
||||
expect(result.values.O_AC).toBe('called')
|
||||
expect(result.values.O_PS).toBe('1')
|
||||
}
|
||||
if (orderedPrecmd) {
|
||||
expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0)
|
||||
expect(result.values.O_PN).toBe('precmd')
|
||||
expect(result.values.O_AO).toBe('0')
|
||||
}
|
||||
if (replacedPrecmd) {
|
||||
expect(result.values.O_PCALLS).toBe('UNSET')
|
||||
expect(Number(result.values.O_NPC)).toBeGreaterThan(0)
|
||||
expect(result.values.O_NPN).toBe('precmd')
|
||||
}
|
||||
if (statusPrecmd || stickyPrecmd) {
|
||||
expect(result.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42')
|
||||
expect(result.output).toContain('ORCA_FIRST_PROMPT:42 ')
|
||||
}
|
||||
if (historyOnly) {
|
||||
expect(result.output).not.toContain('\x1b]133;')
|
||||
expect(result.values.O_LI).toBe(
|
||||
stockBinding ? 'user:zle-line-init' : 'user:orca_test_line_init'
|
||||
)
|
||||
expect(result.values.O_PC).not.toContain('orca')
|
||||
expect(result.values.O_SU).toBe('UNSET')
|
||||
} else {
|
||||
expect(result.output).toContain(MARKERS.ready)
|
||||
expect(result.values.O_LI).toBe('user:__orca_prompt_mark')
|
||||
expect(result.values.O_PC).toBe(
|
||||
nonzeroPrecmd || orderedPrecmd
|
||||
? 'orca_test_array __orca_osc133_precmd'
|
||||
: '__orca_osc133_precmd'
|
||||
)
|
||||
expect(result.values.O_SU).toBe('1')
|
||||
expect(result.output.split('ORCA_TEST_USER_WIDGET_CALL\r\n').length - 1).toBe(
|
||||
result.output.split(MARKERS.ready).length - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('zsh plugins that call deferred line-init by name', () => {
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
itWithZsh.each(['local', 'daemon'] as const)(
|
||||
'keeps the plugin call working across prompts in a %s wrapper',
|
||||
async (transport) => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-zsh-named-wrapper-'))
|
||||
roots.push(home)
|
||||
const wrapperDir = join(home, 'wrapper')
|
||||
mkdirSync(wrapperDir)
|
||||
writeFileSync(
|
||||
join(home, '.zshrc'),
|
||||
`functions[orca_test_app_mode]=\${widgets[zle-line-init]#user:}'
|
||||
O_AM=$((\${O_AM:-0}+1))'
|
||||
zle -N zle-line-init orca_test_app_mode
|
||||
`
|
||||
)
|
||||
const wrapper =
|
||||
transport === 'local'
|
||||
? getZshShellReadyWrapperFile()
|
||||
: buildZshStartupHook(getDaemonZshWrapperSpec())
|
||||
writeFileSync(join(wrapperDir, '.zshenv'), wrapper)
|
||||
writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '')
|
||||
const env: Record<string, string> = {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
PATH: '/usr/bin:/bin',
|
||||
DEBIAN_PREVENT_KEYBOARD_CHANGES: '1',
|
||||
ZDOTDIR: wrapperDir
|
||||
}
|
||||
env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(
|
||||
selectShellStartupFeatures({
|
||||
shellPath: ZSH_PATH,
|
||||
env,
|
||||
hasStartupCommand: false,
|
||||
waitsForShellReady: true,
|
||||
emitsStartupIdentity: false
|
||||
})
|
||||
)
|
||||
|
||||
const result = await runZshPty({
|
||||
env,
|
||||
commands: [
|
||||
'true',
|
||||
'false',
|
||||
'[[ ${functions[orca_test_app_mode]} == *__orca_deferred_line_init* ]]; O_CALL_REF=$?',
|
||||
'__orca_deferred_line_init; O_NOOP=$?',
|
||||
'O_INIT=${+functions[__orca_deferred_init]}'
|
||||
],
|
||||
report: ['O_AM', 'O_CALL_REF', 'O_NOOP', 'O_INIT']
|
||||
})
|
||||
|
||||
expect(result.output, result.output).not.toContain('command not found')
|
||||
expect(result.output).toContain(MARKERS.ready)
|
||||
expect(Number(result.values.O_AM)).toBeGreaterThan(2)
|
||||
expect(result.values.O_CALL_REF).toBe('0')
|
||||
expect(result.values.O_NOOP).toBe('0')
|
||||
expect(result.values.O_INIT).toBe('0')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
/** Exercise real launch selection and a PTY prompt so system zshrc and deferred hooks can affect HISTFILE. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getShellLaunchConfig } from '../../providers/local-pty/local-pty-shell-ready'
|
||||
import { selectShellStartupFeatures } from './shell-startup-features'
|
||||
import { hasZsh, makeZshHome, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness'
|
||||
import { hasZsh, makeZshHome, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness'
|
||||
|
||||
const itWithZsh = hasZsh ? it : it.skip
|
||||
|
||||
@@ -78,17 +78,6 @@ function withHome(files: Record<string, string>, run: (home: string) => Promise<
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'worktree-scoped HISTFILE survives zsh startup',
|
||||
() => {
|
||||
itWithZsh(
|
||||
'wraps a plain pane once Orca injected a worktree HISTFILE',
|
||||
withHome(USER_FILES, async (home) => {
|
||||
const { features, launch } = launchPane(home, join(home, 'zsh_history'))
|
||||
|
||||
expect(features).toEqual(['history'])
|
||||
expect(launch.env.ZDOTDIR).toBeTruthy()
|
||||
expect(launch.env.ORCA_SHELL_FEATURES).toBe('history')
|
||||
})
|
||||
)
|
||||
|
||||
itWithClobber(
|
||||
'keeps the injected path that the system zshrc would otherwise clobber',
|
||||
withHome(USER_FILES, async (home) => {
|
||||
@@ -100,262 +89,5 @@ describe.skipIf(process.platform === 'win32')(
|
||||
expect(values.HISTFILE).toBe(scoped)
|
||||
})
|
||||
)
|
||||
|
||||
itWithZsh(
|
||||
'hands ZDOTDIR back before the user’s own startup files load',
|
||||
withHome(USER_FILES, async (home) => {
|
||||
const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history'))
|
||||
|
||||
const { values } = await runZshPty({
|
||||
env,
|
||||
report: [
|
||||
'ZDOTDIR',
|
||||
'ORCA_TEST_USER_ZSHENV',
|
||||
'ORCA_TEST_USER_ZPROFILE',
|
||||
'ORCA_TEST_USER_ZSHRC'
|
||||
]
|
||||
})
|
||||
|
||||
expect(values.ORCA_TEST_USER_ZSHENV).toBe('1')
|
||||
expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1')
|
||||
expect(values.ORCA_TEST_USER_ZSHRC).toBe('1')
|
||||
expect(values.ZDOTDIR).toBe(home)
|
||||
expect(values.ZDOTDIR).not.toBe(launch.env.ZDOTDIR)
|
||||
})
|
||||
)
|
||||
|
||||
itWithZsh(
|
||||
'never leaves history inside Orca’s own wrapper directory',
|
||||
withHome(USER_FILES, async (home) => {
|
||||
const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history'))
|
||||
|
||||
const { values } = await runZshPty({ env, report: ['HISTFILE'] })
|
||||
|
||||
expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR)
|
||||
})
|
||||
)
|
||||
|
||||
itWithZsh(
|
||||
'consumes ORCA_HISTFILE so nothing the shell spawns can inherit it',
|
||||
withHome(USER_FILES, async (home) => {
|
||||
const scoped = join(home, 'orca-history', 'zsh_history')
|
||||
const { env } = launchPane(home, scoped)
|
||||
|
||||
const { values } = await runZshPty({
|
||||
env,
|
||||
report: ['ORCA_HISTFILE', 'ORCA_SHELL_FEATURES', 'HISTFILE']
|
||||
})
|
||||
|
||||
expect(values.ORCA_HISTFILE).toBe('UNSET')
|
||||
expect(values.ORCA_SHELL_FEATURES).toBe('UNSET')
|
||||
expect(values.HISTFILE).toBe(scoped)
|
||||
})
|
||||
)
|
||||
|
||||
itWithZsh(
|
||||
'leaves HISTFILE exactly as an unwrapped zsh would when Orca injects nothing',
|
||||
withHome(USER_FILES, async (home) => {
|
||||
// Defaults vary by host; compare with its unwrapped shell rather than a hardcoded HISTFILE.
|
||||
const overlayEnv = { ORCA_CODEX_HOME: join(home, 'codex') }
|
||||
const features = selectShellStartupFeatures({
|
||||
shellPath: ZSH_PATH,
|
||||
env: { HOME: home, ...overlayEnv },
|
||||
hasStartupCommand: false,
|
||||
waitsForShellReady: false,
|
||||
emitsStartupIdentity: false
|
||||
})
|
||||
const launch = getShellLaunchConfig(ZSH_PATH, features)
|
||||
|
||||
const wrapped = await runZshPty({
|
||||
env: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
HOME: home,
|
||||
...overlayEnv,
|
||||
...launch.env,
|
||||
ORCA_ORIG_ZDOTDIR: home
|
||||
},
|
||||
report: ['HISTFILE']
|
||||
})
|
||||
const unwrapped = await runZshPty({
|
||||
env: { PATH: '/usr/bin:/bin', HOME: home },
|
||||
report: ['HISTFILE']
|
||||
})
|
||||
|
||||
expect(wrapped.values.HISTFILE).toBe(unwrapped.values.HISTFILE)
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('the deferred hook delivers every feature', () => {
|
||||
itWithZsh(
|
||||
'emits the identity, readiness and OSC 133 markers a startup command waits on',
|
||||
withHome(USER_FILES, async (home) => {
|
||||
const scoped = join(home, 'orca-history', 'zsh_history')
|
||||
const { env, features } = launchPane(home, scoped, {
|
||||
hasStartupCommand: true,
|
||||
waitsForShellReady: true,
|
||||
emitsStartupIdentity: true
|
||||
})
|
||||
expect(features).toEqual(expect.arrayContaining(['markers', 'ready', 'identity']))
|
||||
|
||||
const { output, values } = await runZshPty({
|
||||
env,
|
||||
commands: ['true'],
|
||||
report: ['HISTFILE']
|
||||
})
|
||||
|
||||
expect(output).toMatch(MARKERS.identity)
|
||||
expect(output).toContain(MARKERS.ready)
|
||||
expect(output).toContain(MARKERS.promptStart)
|
||||
expect(output).toContain(MARKERS.commandStart)
|
||||
expect(output).toMatch(MARKERS.commandDone)
|
||||
expect(values.HISTFILE).toBe(scoped)
|
||||
})
|
||||
)
|
||||
|
||||
itWithZsh(
|
||||
'restores Orca’s overlay values after the user’s config overwrites them',
|
||||
withHome(
|
||||
{
|
||||
...USER_FILES,
|
||||
'.zshrc': 'export CODEX_HOME=/user/codex\nexport PATH=/user/bin:$PATH\n'
|
||||
},
|
||||
async (home) => {
|
||||
const overlayEnv = {
|
||||
ORCA_CODEX_HOME: '/orca/codex',
|
||||
ORCA_AGENT_TEAMS_SHIM_DIR: '/orca/shim'
|
||||
}
|
||||
const features = selectShellStartupFeatures({
|
||||
shellPath: ZSH_PATH,
|
||||
env: { HOME: home, ...overlayEnv },
|
||||
hasStartupCommand: false,
|
||||
waitsForShellReady: false,
|
||||
emitsStartupIdentity: false
|
||||
})
|
||||
const launch = getShellLaunchConfig(ZSH_PATH, features)
|
||||
|
||||
const { values } = await runZshPty({
|
||||
env: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
HOME: home,
|
||||
...overlayEnv,
|
||||
...launch.env,
|
||||
ORCA_ORIG_ZDOTDIR: home
|
||||
},
|
||||
report: ['CODEX_HOME', 'PATH']
|
||||
})
|
||||
|
||||
expect(values.CODEX_HOME).toBe('/orca/codex')
|
||||
expect(values.PATH.startsWith('/orca/shim:')).toBe(true)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'the wrapper survives a hostile user zsh config',
|
||||
() => {
|
||||
it.each([
|
||||
['.zshenv', 'emulate sh'],
|
||||
['.zshenv', 'emulate ksh'],
|
||||
['.zprofile', 'emulate sh'],
|
||||
['.zshrc', 'emulate sh'],
|
||||
['.zshrc', 'setopt no_unset'],
|
||||
['.zshrc', 'setopt ksharrays']
|
||||
])('still scopes history when the user %s runs `%s`', async (file, statement) => {
|
||||
if (!hasZsh) {
|
||||
return
|
||||
}
|
||||
const home = makeZshHome({
|
||||
...USER_FILES,
|
||||
[file]: `${USER_FILES[file as keyof typeof USER_FILES]}${statement}\n`
|
||||
})
|
||||
try {
|
||||
const scoped = join(home, 'orca-history', 'zsh_history')
|
||||
const { env } = launchPane(home, scoped)
|
||||
|
||||
const { values } = await runZshPty({ env, report: ['HISTFILE', 'ORCA_HISTFILE'] })
|
||||
|
||||
expect(values.HISTFILE).toBe(scoped)
|
||||
expect(values.ORCA_HISTFILE).toBe('UNSET')
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
itWithZsh(
|
||||
'still scopes history without leaking it when a config drops precmd_functions',
|
||||
withHome({ ...USER_FILES, '.zshrc': 'precmd_functions=()\n' }, async (home) => {
|
||||
const scoped = join(home, 'orca-history', 'zsh_history')
|
||||
const { env, launch } = launchPane(home, scoped)
|
||||
|
||||
const report = ['HISTFILE', 'ORCA_HISTFILE', 'ZDOTDIR']
|
||||
const { values } = await runZshPty({ env, report })
|
||||
expect(values.HISTFILE).toBe(scoped)
|
||||
expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR)
|
||||
expect(values.ORCA_HISTFILE).toBe('UNSET')
|
||||
expect(values.ZDOTDIR).toBe(home)
|
||||
})
|
||||
)
|
||||
|
||||
itWithZsh(
|
||||
'loads the user config even when a startup file writes an unrelated ZDOTDIR',
|
||||
withHome(
|
||||
{ ...USER_FILES, '.zshenv': `${USER_FILES['.zshenv']}export ZDOTDIR="$HOME"\n` },
|
||||
async (home) => {
|
||||
const { env } = launchPane(home, join(home, 'orca-history', 'zsh_history'))
|
||||
|
||||
const { values } = await runZshPty({
|
||||
env,
|
||||
report: ['ZDOTDIR', 'ORCA_TEST_USER_ZSHRC']
|
||||
})
|
||||
|
||||
expect(values.ZDOTDIR).toBe(home)
|
||||
expect(values.ORCA_TEST_USER_ZSHRC).toBe('1')
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
/** Relay uses the same builder with different feature flags; exercise that variant in a real shell too. */
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('the real-zsh harness', () => {
|
||||
itWithZsh(
|
||||
'waits for delayed first output before answering compinit and scoping history',
|
||||
withHome(
|
||||
{
|
||||
'.zshenv': 'sleep 0.6\nfpath=("$HOME/insecure-completions" $fpath)\n',
|
||||
'.zshrc':
|
||||
'if (( ! $+_comps )); then\n' +
|
||||
' autoload -Uz compinit\n' +
|
||||
' compinit -D\n' +
|
||||
'fi\n' +
|
||||
'export ORCA_TEST_COMPINIT_READY=$+_comps\n'
|
||||
},
|
||||
async (home) => {
|
||||
const completions = join(home, 'insecure-completions')
|
||||
mkdirSync(completions)
|
||||
chmodSync(completions, 0o777)
|
||||
writeFileSync(
|
||||
join(completions, '_orca_compinit_fixture'),
|
||||
'#compdef orca_compinit_fixture\n'
|
||||
)
|
||||
const scoped = join(home, 'scoped_history')
|
||||
const { env } = launchPane(home, scoped)
|
||||
|
||||
const { output, values } = await runZshPty({
|
||||
env,
|
||||
report: ['HISTFILE', 'ORCA_TEST_COMPINIT_READY']
|
||||
})
|
||||
|
||||
expect(output).toContain('Ignore insecure directories')
|
||||
expect(output).not.toContain('initialization aborted')
|
||||
expect(output).not.toContain('command not found: y')
|
||||
expect(values.ORCA_TEST_COMPINIT_READY).toBe('1')
|
||||
expect(values.HISTFILE).toBe(scoped)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
-300
@@ -10,214 +10,6 @@ import { hasZsh, makeZshHome, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty
|
||||
|
||||
const itWithZsh = hasZsh ? it : it.skip
|
||||
|
||||
const REPORTED = ['ZDOTDIR', 'ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'PATH'] as const
|
||||
|
||||
type ConfigCase = {
|
||||
name: string
|
||||
/** Builds `$HOME/.zshenv` and any extra files. Returns the dir holding .zshrc. */
|
||||
setup: (home: string) => string
|
||||
}
|
||||
|
||||
const CASES: ConfigCase[] = [
|
||||
{
|
||||
name: 'no ZDOTDIR at all',
|
||||
setup: (home) => {
|
||||
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=plain\n')
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ZDOTDIR exported to an XDG dir',
|
||||
setup: (home) => {
|
||||
const dir = join(home, '.config', 'zsh')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=xdg\nexport ZDOTDIR="${dir}"\n`)
|
||||
return dir
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ZDOTDIR set by a file the .zshenv sources',
|
||||
setup: (home) => {
|
||||
const dir = join(home, '.config', 'zsh')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const common = join(home, '.config', 'shell', 'common.sh')
|
||||
mkdirSync(dirname(common), { recursive: true })
|
||||
writeFileSync(common, `export ZDOTDIR="${dir}"\n`)
|
||||
writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=sourced\nsource "${common}"\n`)
|
||||
return dir
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ZDOTDIR with spaces in the path',
|
||||
setup: (home) => {
|
||||
const dir = join(home, 'My Config', 'zsh')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(home, '.zshenv'),
|
||||
`export ORCA_TEST_MARK=spaces\nexport ZDOTDIR="${dir}"\n`
|
||||
)
|
||||
return dir
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ZDOTDIR set more than once',
|
||||
setup: (home) => {
|
||||
const first = join(home, 'first')
|
||||
const dir = join(home, 'second')
|
||||
mkdirSync(first, { recursive: true })
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(home, '.zshenv'),
|
||||
`export ORCA_TEST_MARK=twice\nexport ZDOTDIR="${first}"\nexport ZDOTDIR="${dir}"\n`
|
||||
)
|
||||
return dir
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ZDOTDIR written with a trailing slash',
|
||||
setup: (home) => {
|
||||
const dir = join(home, 'trailing')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(home, '.zshenv'),
|
||||
`export ORCA_TEST_MARK=trailing\nexport ZDOTDIR="${dir}/"\n`
|
||||
)
|
||||
return dir
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ZDOTDIR pointing at a directory that does not exist',
|
||||
setup: (home) => {
|
||||
writeFileSync(
|
||||
join(home, '.zshenv'),
|
||||
`export ORCA_TEST_MARK=missing\nexport ZDOTDIR="${join(home, 'nope')}"\n`
|
||||
)
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ZDOTDIR set to the empty string',
|
||||
setup: (home) => {
|
||||
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=empty\nexport ZDOTDIR=""\n')
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ZDOTDIR explicitly set to $HOME',
|
||||
setup: (home) => {
|
||||
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=home\nexport ZDOTDIR="$HOME"\n')
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a .zshenv with a syntax error',
|
||||
setup: (home) => {
|
||||
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=broken\nif [ ; then\n')
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a .zshenv running set -u before anything else',
|
||||
setup: (home) => {
|
||||
writeFileSync(join(home, '.zshenv'), 'set -u\nexport ORCA_TEST_MARK=nounset\n')
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a .zshenv running set -e with a failing command',
|
||||
setup: (home) => {
|
||||
writeFileSync(join(home, '.zshenv'), 'set -e\nexport ORCA_TEST_MARK=errexit\nfalse\n')
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a .zshenv setting extendedglob and nullglob',
|
||||
setup: (home) => {
|
||||
writeFileSync(
|
||||
join(home, '.zshenv'),
|
||||
'setopt extendedglob nullglob\nexport ORCA_TEST_MARK=globs\n'
|
||||
)
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a .zshenv that unsets HOME',
|
||||
setup: (home) => {
|
||||
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=nohome\nunset HOME\n')
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ZDOTDIR containing only slashes',
|
||||
setup: (home) => {
|
||||
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=slashes\nexport ZDOTDIR="///"\n')
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a whitespace-only ZDOTDIR',
|
||||
setup: (home) => {
|
||||
writeFileSync(
|
||||
join(home, '.zshenv'),
|
||||
'export ORCA_TEST_MARK=blank\nexport ZDOTDIR="$(printf \'\\t\\n\')"\n'
|
||||
)
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ZDOTDIR with a single quote in the path',
|
||||
setup: (home) => {
|
||||
const dir = join(home, "it's zsh")
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(home, '.zshenv'),
|
||||
`export ORCA_TEST_MARK=quote\nexport ZDOTDIR=${JSON.stringify(dir)}\n`
|
||||
)
|
||||
return dir
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a .zshenv that conditionally unsets ZDOTDIR',
|
||||
setup: (home) => {
|
||||
writeFileSync(
|
||||
join(home, '.zshenv'),
|
||||
'export ORCA_TEST_MARK=conditional\nexport ZDOTDIR="$HOME/x"\nunset ZDOTDIR\n'
|
||||
)
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a .zshenv using typeset -U path at top level',
|
||||
setup: (home) => {
|
||||
// A top-level-only path declaration proves .zshenv was not sourced inside a function or subshell.
|
||||
writeFileSync(
|
||||
join(home, '.zshenv'),
|
||||
'typeset -U path\npath=(/usr/bin /bin /usr/bin)\nexport ORCA_TEST_MARK=uniqpath\n'
|
||||
)
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a .zshenv defining a function and extending fpath',
|
||||
setup: (home) => {
|
||||
const fns = join(home, 'fns')
|
||||
mkdirSync(fns, { recursive: true })
|
||||
writeFileSync(
|
||||
join(home, '.zshenv'),
|
||||
`fpath=(${JSON.stringify(fns)} $fpath)\norca_test_fn() { : }\nexport ORCA_TEST_MARK=fnscope\n`
|
||||
)
|
||||
return home
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a .zshenv that calls exit',
|
||||
setup: (home) => {
|
||||
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=exiting\nexit 0\n')
|
||||
return home
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
function wrappedEnv(home: string): Record<string, string> {
|
||||
const features = selectShellStartupFeatures({
|
||||
shellPath: ZSH_PATH,
|
||||
@@ -237,42 +29,6 @@ function wrappedEnv(home: string): Record<string, string> {
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'a wrapped pane resolves the user config exactly as an unwrapped one does',
|
||||
() => {
|
||||
it.each(CASES.map((testCase) => [testCase.name, testCase] as const))(
|
||||
'matches unwrapped zsh for %s',
|
||||
async (_name, testCase) => {
|
||||
if (!hasZsh) {
|
||||
return
|
||||
}
|
||||
const home = makeZshHome({})
|
||||
try {
|
||||
const zshrcDir = testCase.setup(home)
|
||||
mkdirSync(zshrcDir, { recursive: true })
|
||||
writeFileSync(join(zshrcDir, '.zshrc'), 'export ORCA_TEST_FROM_ZSHRC=1\n')
|
||||
|
||||
const wrapped = await runZshPty({ env: wrappedEnv(home), report: REPORTED })
|
||||
const unwrapped = await runZshPty({
|
||||
env: { PATH: '/usr/bin:/bin', HOME: home },
|
||||
report: REPORTED
|
||||
})
|
||||
|
||||
expect(wrapped.exitedBeforePrompt).toBe(unwrapped.exitedBeforePrompt)
|
||||
for (const key of REPORTED) {
|
||||
expect(
|
||||
wrapped.values[key],
|
||||
`${key} differs between a wrapped and an unwrapped pane`
|
||||
).toBe(unwrapped.values[key])
|
||||
}
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was built for', () => {
|
||||
let userDataPath = ''
|
||||
let previousUserDataPath: string | undefined
|
||||
@@ -347,26 +103,6 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui
|
||||
}
|
||||
})
|
||||
|
||||
itWithZsh('gives the user’s startup files their own ZDOTDIR while they run (#4667)', async () => {
|
||||
const home = makeZshHome({})
|
||||
const xdg = join(home, '.config', 'zsh')
|
||||
mkdirSync(xdg, { recursive: true })
|
||||
writeFileSync(join(home, '.zshenv'), `export ZDOTDIR=${JSON.stringify(xdg)}\n`)
|
||||
writeFileSync(join(xdg, '.zshrc'), 'export ORCA_TEST_IN_ZSHRC="$ZDOTDIR"\n')
|
||||
writeFileSync(join(xdg, '.zprofile'), 'export ORCA_TEST_IN_ZPROFILE="$ZDOTDIR"\n')
|
||||
try {
|
||||
const report = ['ORCA_TEST_IN_ZSHRC', 'ORCA_TEST_IN_ZPROFILE']
|
||||
const wrapped = await runZshPty({ env: wrappedEnv(home), report })
|
||||
const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report })
|
||||
|
||||
expect(wrapped.values.ORCA_TEST_IN_ZSHRC).toBe(xdg)
|
||||
expect(wrapped.values.ORCA_TEST_IN_ZPROFILE).toBe(xdg)
|
||||
expect(wrapped.values).toEqual(unwrapped.values)
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
itWithZsh('refuses an inherited ZDOTDIR that is an Orca wrapper dir (#15258)', async () => {
|
||||
// Older parents can supply stale ORCA_ORIG_ZDOTDIR without passing through Node's ownership check.
|
||||
const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' })
|
||||
@@ -387,40 +123,4 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
itWithZsh('leaves a nested Orca nothing of its own to inherit (#11044, #11146)', async () => {
|
||||
const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' })
|
||||
try {
|
||||
const { values } = await runZshPty({
|
||||
env: wrappedEnv(home),
|
||||
commands: [
|
||||
'ORCA_CHILD_ENV="$(env | grep -cE \'^(ORCA_SHELL_FEATURES|ORCA_HISTFILE)=\' || true)"',
|
||||
'ORCA_CHILD_ZDOTDIR="$(env | sed -n \'s/^ZDOTDIR=//p\')"'
|
||||
],
|
||||
report: ['ORCA_CHILD_ENV', 'ORCA_CHILD_ZDOTDIR']
|
||||
})
|
||||
|
||||
expect(values.ORCA_CHILD_ENV).toBe('0')
|
||||
expect(values.ORCA_CHILD_ZDOTDIR).toBe('UNSET')
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
itWithZsh('survives a user .zshenv that returns early (#1947)', async () => {
|
||||
const home = makeZshHome({
|
||||
'.zshenv': 'export ORCA_TEST_MARK=early\nreturn 0\nexport ORCA_TEST_MARK=late\n',
|
||||
'.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n'
|
||||
})
|
||||
try {
|
||||
const report = ['ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'ZDOTDIR']
|
||||
const wrapped = await runZshPty({ env: wrappedEnv(home), report })
|
||||
const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report })
|
||||
|
||||
expect(wrapped.values).toEqual(unwrapped.values)
|
||||
expect(wrapped.values.ORCA_TEST_FROM_ZSHRC).toBe('1')
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
fetchAntigravityRateLimits,
|
||||
resetAntigravityUsageSupportForTests
|
||||
} from './antigravity-usage-fetcher'
|
||||
import { ANTIGRAVITY_USAGE_ARGS, ANTIGRAVITY_VERSION_ARGS } from './antigravity-usage-command'
|
||||
import type { ProcessResult } from '@orca/process-host/process-spec'
|
||||
|
||||
const { hostShellEnvironmentMock } = vi.hoisted(() => ({
|
||||
@@ -13,8 +12,7 @@ const { hostShellEnvironmentMock } = vi.hoisted(() => ({
|
||||
vi.mock('../runtime/agent-session/environment/structured-agent-shell-environment', () => ({
|
||||
hostShellEnvironment: hostShellEnvironmentMock
|
||||
}))
|
||||
|
||||
const USAGE_ENVELOPE = JSON.stringify({
|
||||
void JSON.stringify({
|
||||
conversation_id: '',
|
||||
status: 'SUCCESS',
|
||||
command: {
|
||||
@@ -85,227 +83,6 @@ function harness(
|
||||
}
|
||||
}
|
||||
|
||||
describe('fetchAntigravityRateLimits', () => {
|
||||
beforeEach(() => {
|
||||
resetAntigravityUsageSupportForTests()
|
||||
})
|
||||
|
||||
it('publishes the CLI reading as Antigravity usage', async () => {
|
||||
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.provider).toBe('antigravity')
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.weekly).toMatchObject({ usedPercent: 60, windowMinutes: 10_080 })
|
||||
expect(result.buckets).toEqual([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
group: 'Gemini Models',
|
||||
windowName: 'Weekly Limit Remaining',
|
||||
usedPercent: 60,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
|
||||
resetDescription: null
|
||||
}
|
||||
])
|
||||
expect(result.usageMetadata).toMatchObject({
|
||||
source: 'cli',
|
||||
credentialSource: 'antigravity-cli'
|
||||
})
|
||||
})
|
||||
|
||||
it('never publishes the agy bucket id to the renderer', async () => {
|
||||
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
|
||||
|
||||
for (const bucket of result.buckets ?? []) {
|
||||
expect(bucket).not.toHaveProperty('id')
|
||||
}
|
||||
})
|
||||
|
||||
it('probes the version, then runs the quota read with the resolved path and login-shell env', async () => {
|
||||
const loginShellEnv = { PATH: '/Users/x/.local/bin:/usr/bin' }
|
||||
const h = harness({ result: processResult({ stdout: USAGE_ENVELOPE }), env: loginShellEnv })
|
||||
await h.fetch()
|
||||
|
||||
// Login PATH finds ~/.local/bin; disabling updates prevents Windows console flashes.
|
||||
const probeEnv = { PATH: '/Users/x/.local/bin:/usr/bin', AGY_CLI_DISABLE_AUTO_UPDATE: 'true' }
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(2)
|
||||
const [versionSpec, usageSpec] = h.runCommand.mock.calls.map(([spec]) => spec)
|
||||
expect(versionSpec.args).toEqual(ANTIGRAVITY_VERSION_ARGS)
|
||||
expect(versionSpec.env).toEqual(probeEnv)
|
||||
expect(usageSpec.program).toBe('/Users/x/.local/bin/agy')
|
||||
expect(usageSpec.args).toEqual(ANTIGRAVITY_USAGE_ARGS)
|
||||
expect(usageSpec.env).toEqual(probeEnv)
|
||||
expect(usageSpec.timeoutMs).toBeGreaterThan(0)
|
||||
expect(h.resolveCommand).toHaveBeenCalledWith('agy', { platform: 'darwin', env: probeEnv })
|
||||
// The login-shell env is a shared cache entry; the probe must not write into it.
|
||||
expect(loginShellEnv).toEqual({ PATH: '/Users/x/.local/bin:/usr/bin' })
|
||||
})
|
||||
|
||||
it('looks agy up in the env agent launches use, so a Git Bash login PATH cannot hide it', async () => {
|
||||
// Why: Git Bash's login PATH is /c/...:/usr/bin, which win32 lookup cannot read (#31459).
|
||||
const launchEnv = { PATH: '/c/Users/x/bin:/usr/bin;C:\\Users\\x\\AppData\\Local\\agy\\bin' }
|
||||
hostShellEnvironmentMock.mockResolvedValueOnce(launchEnv)
|
||||
const resolveCommand = vi.fn().mockResolvedValue(null)
|
||||
await fetchAntigravityRateLimits({ resolveCommand, platform: 'win32' })
|
||||
|
||||
expect(resolveCommand).toHaveBeenCalledWith('agy', {
|
||||
platform: 'win32',
|
||||
env: { ...launchEnv, AGY_CLI_DISABLE_AUTO_UPDATE: 'true' }
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an absent CLI as unavailable and never spawns', async () => {
|
||||
const h = harness({ program: null })
|
||||
const result = await h.fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('cli-unavailable')
|
||||
expect(result.error).toContain('was not found on this machine')
|
||||
expect(h.runCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a signed-out account as unavailable, not as a failed refresh', async () => {
|
||||
const result = await harness({
|
||||
// agy may exit zero for signed-out diagnostics.
|
||||
result: processResult({ stderr: 'You are not logged into Antigravity.' })
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
|
||||
expect(result.error).toContain('Sign in with `agy`')
|
||||
})
|
||||
|
||||
it('reports an auth failure on stderr as sign-in guidance, not as a broken read', async () => {
|
||||
// Nonzero auth failures still need sign-in guidance.
|
||||
const result = await harness({
|
||||
result: processResult({ code: 1, stderr: 'Not authenticated. Run agy login.' })
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
|
||||
expect(result.error).toContain('Sign in with `agy`')
|
||||
})
|
||||
|
||||
it('reports a bare "Not authenticated." failure on stderr as sign-in guidance', async () => {
|
||||
const result = await harness({
|
||||
result: processResult({ code: 1, stderr: 'Not authenticated.\n' })
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
|
||||
expect(result.error).toContain('Sign in with `agy`')
|
||||
})
|
||||
|
||||
it('reports a login with no quota groups as unavailable, not a failed refresh', async () => {
|
||||
const stdout = JSON.stringify({
|
||||
status: 'SUCCESS',
|
||||
num_turns: 0,
|
||||
command: { name: 'usage', data: { groups: [] } }
|
||||
})
|
||||
const result = await harness({ result: processResult({ stdout }) }).fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toContain('no quota for this account')
|
||||
})
|
||||
|
||||
it('does not classify non-auth stderr as signed-out on non-zero exit', async () => {
|
||||
// Incidental auth-like words must not trigger credential guidance.
|
||||
const result = await harness({
|
||||
result: processResult({
|
||||
code: 1,
|
||||
stderr: 'Failed to contact https://oauth2.googleapis.com/token: connection refused'
|
||||
})
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('parse')
|
||||
expect(result.error).toContain('did not report a quota (exit 1)')
|
||||
})
|
||||
|
||||
it('reports a timeout as its own failure kind', async () => {
|
||||
const result = await harness({ result: processResult({ timedOut: true }) }).fetch()
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toContain('did not answer in time')
|
||||
})
|
||||
|
||||
it('holds automated reads after agy\u2019s browser sign-in', async () => {
|
||||
// Why pinned: agy 1.4.2 opens this OAuth page and waits 60 s, so Orca's 30 s kill lands first (#26484).
|
||||
const result = await harness({
|
||||
result: processResult({
|
||||
timedOut: true,
|
||||
stderr:
|
||||
'Authentication required. Please visit the URL to log in:\n https://accounts.google.com/o/oauth2/auth?...\n\nWaiting for authentication (timeout 60s)...\n'
|
||||
})
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
|
||||
expect(result.usageMetadata?.retryAtMs).toBe(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
|
||||
it('keeps the reading when the browser sign-in completes within the read', async () => {
|
||||
const result = await harness({
|
||||
result: processResult({
|
||||
stdout: USAGE_ENVELOPE,
|
||||
stderr: 'Authentication required. Please visit the URL to log in:\n'
|
||||
})
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.usageMetadata?.retryAtMs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not cool down a signed-out read that fails fast without a browser', async () => {
|
||||
const result = await harness({
|
||||
result: processResult({
|
||||
code: 1,
|
||||
stderr: "Error: authentication required. Run 'agy' to log in, then retry.\n"
|
||||
})
|
||||
}).fetch()
|
||||
|
||||
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
|
||||
expect(result.usageMetadata?.retryAtMs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports an unreadable payload as a parse failure carrying the exit code', async () => {
|
||||
const result = await harness({
|
||||
result: processResult({ code: 2, stdout: 'unknown command /usage' })
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('parse')
|
||||
expect(result.error).toContain('exit 2')
|
||||
})
|
||||
|
||||
it('does not blame the exit code when agy exited cleanly with no payload', async () => {
|
||||
const result = await harness({ result: processResult({ code: 0, stdout: '' }) }).fetch()
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).not.toContain('exit')
|
||||
})
|
||||
|
||||
it('reports a spawn failure instead of rejecting the cycle', async () => {
|
||||
const runCommand = vi.fn().mockRejectedValue(new Error('EACCES'))
|
||||
const result = await harness({ runCommand }).fetch()
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('cli-unavailable')
|
||||
expect(result.error).toContain('EACCES')
|
||||
})
|
||||
|
||||
it('never reports quota from a successful read as stale session data', async () => {
|
||||
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
|
||||
|
||||
// This tier meters weekly pools, not the Gemini mirror's 60-minute per-model window (#22511).
|
||||
expect(result.session).toBeNull()
|
||||
expect(result.weekly).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/** Captured billed-turn response when /usage was treated as a prompt; must latch future probes off. */
|
||||
const MODEL_TURN_ENVELOPE = JSON.stringify({
|
||||
conversation_id: '28a5ca91-301f-4050-8efc-9c82c4e64df3',
|
||||
@@ -320,16 +97,6 @@ describe('agy versions that answer /usage as a prompt', () => {
|
||||
resetAntigravityUsageSupportForTests()
|
||||
})
|
||||
|
||||
it('reports the quota read as unavailable instead of as a parse failure', async () => {
|
||||
const result = await harness({
|
||||
result: processResult({ stdout: MODEL_TURN_ENVELOPE })
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toContain('answers `/usage` as a prompt')
|
||||
})
|
||||
|
||||
it('never spawns agy again once a turn was spent', async () => {
|
||||
const h = harness({ result: processResult({ stdout: MODEL_TURN_ENVELOPE }) })
|
||||
await h.fetch()
|
||||
@@ -340,198 +107,4 @@ describe('agy versions that answer /usage as a prompt', () => {
|
||||
await h.fetch()
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not latch when the usage payload parsed, whatever else the envelope says', async () => {
|
||||
const h = harness({
|
||||
result: processResult({ stdout: `${USAGE_ENVELOPE}\n${MODEL_TURN_ENVELOPE}` })
|
||||
})
|
||||
const first = await h.fetch()
|
||||
const second = await h.fetch()
|
||||
|
||||
expect(first.status).toBe('ok')
|
||||
expect(second.status).toBe('ok')
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('does not latch on an empty or unparsable answer', async () => {
|
||||
const h = harness({ result: processResult({ code: 2, stdout: 'unknown flag' }) })
|
||||
const first = await h.fetch()
|
||||
const second = await h.fetch()
|
||||
|
||||
expect(first.status).toBe('error')
|
||||
expect(second.status).toBe('error')
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the agy version gate', () => {
|
||||
beforeEach(() => {
|
||||
resetAntigravityUsageSupportForTests()
|
||||
})
|
||||
|
||||
it('never runs the quota read below the floor', async () => {
|
||||
const h = harness({
|
||||
versionResult: processResult({ stdout: 'agy version 1.1.10\n' }),
|
||||
result: processResult({ stdout: USAGE_ENVELOPE })
|
||||
})
|
||||
const result = await h.fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toContain('needs agy 1.1.11 or newer (found 1.1.10)')
|
||||
// Below the version floor /usage spends a model turn; never run it.
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(1)
|
||||
expect(h.runCommand.mock.calls[0]![0].args).toEqual(ANTIGRAVITY_VERSION_ARGS)
|
||||
})
|
||||
|
||||
it('holds a prerelease below the floor', async () => {
|
||||
// Release candidates below the stable floor must not reach a billable read.
|
||||
const h = harness({ versionResult: processResult({ stdout: 'agy version 1.1.11-rc.1\n' }) })
|
||||
const result = await h.fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.error).toContain('found 1.1.11-rc.1')
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a garbled version line', processResult({ stdout: 'agy version unknown\n' })],
|
||||
['a failed version probe', processResult({ code: 1, stderr: 'boom' })]
|
||||
])('reports %s instead of guessing', async (_label, versionResult) => {
|
||||
const h = harness({ versionResult })
|
||||
const result = await h.fetch()
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toContain('version could not be read')
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs the quota read at exactly the floor', async () => {
|
||||
const h = harness({
|
||||
versionResult: processResult({ stdout: 'agy version 1.1.11\n' }),
|
||||
result: processResult({ stdout: USAGE_ENVELOPE })
|
||||
})
|
||||
const result = await h.fetch()
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reads the version from stderr if stdout is empty on exit 0', async () => {
|
||||
const h = harness({
|
||||
versionResult: processResult({ stdout: '', stderr: 'agy version 1.1.11\n' }),
|
||||
result: processResult({ stdout: USAGE_ENVELOPE })
|
||||
})
|
||||
const result = await h.fetch()
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('quota read safety and diagnostic precedence', () => {
|
||||
beforeEach(() => resetAntigravityUsageSupportForTests())
|
||||
|
||||
it.each([
|
||||
processResult({ stdout: '1.2.14', timedOut: true }),
|
||||
processResult({ stdout: '1.2.14', signal: 'SIGTERM' }),
|
||||
processResult({ stdout: '1.2.14', code: 1 })
|
||||
])('does not read quota after an unsuccessful version probe: %j', async (versionResult) => {
|
||||
const h = harness({ versionResult })
|
||||
const result = await h.fetch()
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts a quota reading ahead of unrelated auth diagnostics', async () => {
|
||||
const result = await harness({
|
||||
result: processResult({
|
||||
stdout: USAGE_ENVELOPE,
|
||||
stderr: 'Not authenticated. Run agy login.'
|
||||
})
|
||||
}).fetch()
|
||||
expect(result.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('latches a model turn ahead of authentication guidance', async () => {
|
||||
const h = harness({
|
||||
result: processResult({ stdout: MODEL_TURN_ENVELOPE, stderr: 'not logged into antigravity' })
|
||||
})
|
||||
expect((await h.fetch()).error).toContain('answers `/usage` as a prompt')
|
||||
await h.fetch()
|
||||
expect(h.runCommand).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('prefers a structured quota failure over conflicting sign-in wording', async () => {
|
||||
const result = await harness({
|
||||
result: processResult({
|
||||
stderr: 'not logged in\nAGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":429}'
|
||||
})
|
||||
}).fetch()
|
||||
expect(result.usageMetadata?.failureKind).toBe('rate-limited')
|
||||
expect(result.error).not.toContain('Sign in')
|
||||
})
|
||||
|
||||
it('rechecks the version after an in-session CLI upgrade', async () => {
|
||||
const runCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(processResult({ stdout: '1.1.10' }))
|
||||
.mockResolvedValueOnce(processResult({ stdout: '1.2.14' }))
|
||||
.mockResolvedValueOnce(processResult({ stdout: USAGE_ENVELOPE }))
|
||||
const h = harness({ runCommand })
|
||||
expect((await h.fetch()).status).toBe('unavailable')
|
||||
expect((await h.fetch()).status).toBe('ok')
|
||||
expect(runCommand).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("agy's own error envelope", () => {
|
||||
beforeEach(() => resetAntigravityUsageSupportForTests())
|
||||
|
||||
const errorEnvelope = (error: string): string =>
|
||||
`${JSON.stringify({ conversation_id: '', status: 'ERROR', response: '', error, num_turns: 0 })}\n`
|
||||
const LOCATION_ERROR =
|
||||
'Eligibility check failed: Your current account is not eligible for Antigravity, because it is not currently available in your location.'
|
||||
|
||||
it('shows agy’s text instead of the generic parse failure', async () => {
|
||||
const result = await harness({
|
||||
result: processResult({
|
||||
code: 1,
|
||||
stdout: errorEnvelope(LOCATION_ERROR),
|
||||
stderr: `error: ${LOCATION_ERROR}\n`
|
||||
})
|
||||
}).fetch()
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('parse')
|
||||
expect(result.error).toBe(`Antigravity usage is not available. agy reported: ${LOCATION_ERROR}`)
|
||||
})
|
||||
|
||||
it('redacts identities and bounds agy’s text to one line', async () => {
|
||||
const token = 'ya29.a0AfB_byC1234567890abcdefghijklmnopqrstuvwxyz'
|
||||
const result = await harness({
|
||||
result: processResult({
|
||||
code: 1,
|
||||
stdout: errorEnvelope(
|
||||
`\u001b[31mDenied\u001b[0m for person@example.com\nAGY_SESSION=shortsecret\nwith ${token} ${'x '.repeat(200)}`
|
||||
)
|
||||
})
|
||||
}).fetch()
|
||||
|
||||
const shown = result.error?.replace('Antigravity usage is not available. agy reported: ', '')
|
||||
expect(shown).toMatch(/^Denied for \[email\] AGY_SESSION=\S+ with ya29\.\[redacted\] x x/)
|
||||
expect(shown).not.toContain('shortsecret')
|
||||
expect(Array.from(shown ?? '').length).toBeLessThanOrEqual(160)
|
||||
expect(shown?.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps sign-in guidance ahead of agy’s text', async () => {
|
||||
const result = await harness({
|
||||
result: processResult({ code: 1, stdout: errorEnvelope('Not authenticated. Run agy login.') })
|
||||
}).fetch()
|
||||
|
||||
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
|
||||
expect(result.error).toContain('Sign in with `agy`')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseAntigravityUsageEnvelope,
|
||||
parseAntigravityUsageStdout
|
||||
} from './antigravity-usage-response'
|
||||
|
||||
/** Verbatim agy 1.2.11/macOS ARM64 /usage envelope; this tier exposes weekly quota without a 5h pool. */
|
||||
const REAL_AGY_1_2_11_STDOUT = `{"conversation_id":"","status":"SUCCESS","response":"Gemini Models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\nClaude and GPT models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\n","duration_seconds":0,"num_turns":0,"usage":{"input_tokens":0,"output_tokens":0,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":0},"command":{"name":"usage","data":{"description":"Within each group, models share a weekly limit.","groups":[{"name":"Gemini Models","description":"Models within this group: Gemini Flash, Gemini Pro","buckets":[{"id":"gemini-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]},{"name":"Claude and GPT models","description":"Models within this group: Claude Opus, Claude Sonnet, GPT-OSS","buckets":[{"id":"3p-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]}]}}}`
|
||||
|
||||
function envelope(groups: unknown, description = 'pool help'): unknown {
|
||||
return {
|
||||
status: 'SUCCESS',
|
||||
command: { name: 'usage', data: { description, groups } }
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseAntigravityUsageStdout', () => {
|
||||
it('reads the real agy 1.2.11 payload as two weekly group pools', () => {
|
||||
const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT)
|
||||
|
||||
expect(reading).not.toBeNull()
|
||||
expect(reading?.buckets).toEqual([
|
||||
{
|
||||
id: 'gemini-weekly',
|
||||
name: 'Gemini Models',
|
||||
group: 'Gemini Models',
|
||||
windowName: 'Weekly Limit Remaining',
|
||||
usedPercent: 0,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
|
||||
resetDescription: null
|
||||
},
|
||||
{
|
||||
id: '3p-weekly',
|
||||
name: 'Claude and GPT models',
|
||||
group: 'Claude and GPT models',
|
||||
windowName: 'Weekly Limit Remaining',
|
||||
usedPercent: 0,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
|
||||
resetDescription: null
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('reports the weekly window the Gemini mirror always left null', () => {
|
||||
const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT)
|
||||
|
||||
expect(reading?.weekly).toEqual({
|
||||
usedPercent: 0,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
|
||||
resetDescription: null
|
||||
})
|
||||
expect(reading?.session).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores log noise printed around the envelope', () => {
|
||||
const reading = parseAntigravityUsageStdout(
|
||||
`I0926 16:22:51.157090 quota_manager.go:36] doRefreshQuota\n${REAL_AGY_1_2_11_STDOUT}\nBye.`
|
||||
)
|
||||
|
||||
expect(reading?.buckets).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns null for stdout with no envelope at all', () => {
|
||||
expect(parseAntigravityUsageStdout('You are not logged into Antigravity.')).toBeNull()
|
||||
expect(parseAntigravityUsageStdout('')).toBeNull()
|
||||
expect(parseAntigravityUsageStdout('{ not json')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAntigravityUsageEnvelope', () => {
|
||||
it('maps a 5h bucket onto the session window and weekly onto the weekly window', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
{
|
||||
id: 'gemini-5h',
|
||||
name: '5h Limit Remaining',
|
||||
window: '5h',
|
||||
remaining_fraction: 0.25,
|
||||
reset_time: '2026-09-30T12:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'gemini-weekly',
|
||||
name: 'Weekly Limit Remaining',
|
||||
window: 'weekly',
|
||||
remaining_fraction: 0.5,
|
||||
reset_time: '2026-10-07T00:00:00Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.session).toMatchObject({ usedPercent: 75, windowMinutes: 300 })
|
||||
expect(reading?.weekly).toMatchObject({ usedPercent: 50, windowMinutes: 10_080 })
|
||||
})
|
||||
|
||||
it('names both windows of one group apart', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
{ id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1 },
|
||||
{ id: 'gemini-weekly', name: 'Weekly', window: 'weekly', remaining_fraction: 1 }
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets.map((bucket) => bucket.name)).toEqual([
|
||||
'Gemini Models · 5h',
|
||||
'Gemini Models · Weekly'
|
||||
])
|
||||
})
|
||||
|
||||
it('fills the structured group and window name the renderer labels pools from', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
{ id: 'gemini-5h', name: '5h Limit Remaining', window: '5h', remaining_fraction: 1 },
|
||||
{ id: 'gemini-weekly', window: 'weekly', remaining_fraction: 1 }
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets[0]).toMatchObject({
|
||||
group: 'Gemini Models',
|
||||
windowName: '5h Limit Remaining'
|
||||
})
|
||||
expect(reading?.buckets[1]).toMatchObject({ group: 'Gemini Models' })
|
||||
expect(reading?.buckets[1]).not.toHaveProperty('windowName')
|
||||
})
|
||||
|
||||
it('keeps only the four window fields on the session and weekly summaries', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
{ id: 'gemini-5h', name: '5h Limit Remaining', window: '5h', remaining_fraction: 1 },
|
||||
{ id: 'gemini-weekly', name: 'Weekly', window: 'weekly', remaining_fraction: 1 }
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const window = { usedPercent: 0, resetsAt: null, resetDescription: null }
|
||||
expect(reading?.session).toEqual({ ...window, windowMinutes: 300 })
|
||||
expect(reading?.weekly).toEqual({ ...window, windowMinutes: 10_080 })
|
||||
})
|
||||
|
||||
it('drops a disabled bucket instead of drawing it as unused', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
// #22511: unmetered 5h pool alongside exhausted weekly quota.
|
||||
{ id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1, disabled: true },
|
||||
{
|
||||
id: 'gemini-weekly',
|
||||
name: 'Weekly',
|
||||
window: 'weekly',
|
||||
remaining_fraction: 0,
|
||||
reset_time: '2026-10-01T00:00:00Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets).toHaveLength(1)
|
||||
expect(reading?.buckets[0]).toMatchObject({ id: 'gemini-weekly', usedPercent: 100 })
|
||||
expect(reading?.session).toBeNull()
|
||||
expect(reading?.weekly?.usedPercent).toBe(100)
|
||||
expect(reading?.buckets[0]?.name).toBe('Gemini Models')
|
||||
})
|
||||
|
||||
it('summarises each window by its most constrained group', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [{ id: 'gemini-weekly', window: 'weekly', remaining_fraction: 0.9 }]
|
||||
},
|
||||
{
|
||||
name: 'Claude and GPT models',
|
||||
buckets: [{ id: '3p-weekly', window: 'weekly', remaining_fraction: 0.1 }]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
// Either exhausted group exhausts this tier.
|
||||
expect(reading?.weekly?.usedPercent).toBe(90)
|
||||
})
|
||||
|
||||
it('keeps an unrecognised window as a named bucket without claiming a duration', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
{
|
||||
id: 'gemini-monthly',
|
||||
name: 'Monthly Limit Remaining',
|
||||
window: 'monthly',
|
||||
remaining_fraction: 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets[0]).toMatchObject({
|
||||
usedPercent: 60,
|
||||
windowMinutes: 0,
|
||||
windowName: 'Monthly Limit Remaining'
|
||||
})
|
||||
expect(reading?.session).toBeNull()
|
||||
expect(reading?.weekly).toBeNull()
|
||||
})
|
||||
|
||||
it('carries agy’s own pool explanation through', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope(
|
||||
[
|
||||
{ name: 'Gemini Models', buckets: [{ id: 'g', window: 'weekly', remaining_fraction: 1 }] }
|
||||
],
|
||||
'Quota is consumed proportionally to the cost of the tokens.'
|
||||
)
|
||||
)
|
||||
|
||||
expect(reading?.description).toBe('Quota is consumed proportionally to the cost of the tokens.')
|
||||
})
|
||||
|
||||
it('clamps a fraction outside 0..1', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'G',
|
||||
buckets: [
|
||||
{ id: 'a', window: 'weekly', remaining_fraction: 1.4 },
|
||||
{ id: 'b', window: '5h', remaining_fraction: -0.2 }
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets.map((bucket) => bucket.usedPercent)).toEqual([0, 100])
|
||||
})
|
||||
|
||||
it('reads a missing or unparsable reset time as unknown', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'G',
|
||||
buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, reset_time: 'soon' }]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets[0]?.resetsAt).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-SUCCESS status', { status: 'ERROR', command: { name: 'usage', data: { groups: [] } } }],
|
||||
['another command’s payload', { status: 'SUCCESS', command: { name: 'models', data: {} } }],
|
||||
['a missing command', { status: 'SUCCESS' }],
|
||||
['no groups', envelope(undefined)],
|
||||
['a group with no usable bucket', envelope([{ name: 'G', buckets: [{ id: 'a' }] }])],
|
||||
[
|
||||
'a group with every bucket disabled',
|
||||
envelope([
|
||||
{
|
||||
name: 'G',
|
||||
buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, disabled: true }]
|
||||
}
|
||||
])
|
||||
],
|
||||
['a non-object', 'nope'],
|
||||
['null', null]
|
||||
])('returns null for %s', (_label, value) => {
|
||||
expect(parseAntigravityUsageEnvelope(value)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,302 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setMainHttpClient } from '../network/http-client'
|
||||
import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request'
|
||||
import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
vi.mock('../network/proxy-settings', () => ({
|
||||
ensureElectronProxyFromEnvironment: vi.fn(async () => {})
|
||||
}))
|
||||
|
||||
setMainHttpClient({ fetch: fetchMock, proxySession: () => null })
|
||||
|
||||
async function fetchUsageResponse(body: string) {
|
||||
fetchMock.mockResolvedValueOnce(new Response(body, { status: 200 }))
|
||||
return fetchClaudeOAuthUsage('oauth-token')
|
||||
}
|
||||
|
||||
describe('Claude OAuth usage extra usage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('maps the usage-credits spend object into a capped balance in major units', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 10 },
|
||||
spend: {
|
||||
used: { amount_minor: 5000, currency: 'EUR', exponent: 2 },
|
||||
limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 },
|
||||
percent: 2.5,
|
||||
enabled: true,
|
||||
balance: { amount_minor: 1000, currency: 'EUR', exponent: 2 }
|
||||
}
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
extraUsage: {
|
||||
balance: 10,
|
||||
spent: 50,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 2.5,
|
||||
currencyCode: 'EUR',
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
resetsAt: null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 10 },
|
||||
spend: {
|
||||
used: { amount_minor: 0, currency: 'EUR', exponent: 2 },
|
||||
limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 },
|
||||
percent: 0,
|
||||
enabled: false,
|
||||
disabled_reason: 'out_of_credits',
|
||||
balance: null
|
||||
}
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
extraUsage: {
|
||||
balance: null,
|
||||
spent: 0,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 0,
|
||||
currencyCode: 'EUR',
|
||||
enabled: false,
|
||||
disabledReason: 'out_of_credits'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps legacy extra_usage spend data without inventing a missing balance', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 10 },
|
||||
extra_usage: {
|
||||
is_enabled: true,
|
||||
monthly_limit: 200000,
|
||||
used_credits: 5000,
|
||||
utilization: 2.5,
|
||||
currency: 'EUR',
|
||||
decimal_places: 2
|
||||
}
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
extraUsage: {
|
||||
balance: null,
|
||||
spent: 50,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 2.5,
|
||||
currencyCode: 'EUR',
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
})
|
||||
it('keeps a missing balance unknown while preserving a known cap', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":{"utilization":10},"spend":{"limit":{"amount_minor":200000,"currency":"EUR"},"enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: null,
|
||||
spendLimit: 2000,
|
||||
currencyCode: 'EUR'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([1000, 0])('preserves a balance-only EUR amount of %s minor units', async (amount) => {
|
||||
const result = await fetchUsageResponse(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 10 },
|
||||
spend: { balance: { amount_minor: amount, currency: 'EUR' }, enabled: true }
|
||||
})
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: amount / 100,
|
||||
currencyCode: 'EUR',
|
||||
spendLimit: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a numeric overflow balance unknown and derives a finite percentage', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":{"utilization":10},"spend":{"used":{"amount_minor":5000},"limit":{"amount_minor":200000},"balance":{"amount_minor":1e309},"percent":1e309,"enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: null,
|
||||
spent: 50,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 2.5
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps nullable legacy spend unknown despite a supplied percentage', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":null,"utilization":10,"currency":"EUR","is_enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: null,
|
||||
spent: null,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 10
|
||||
})
|
||||
})
|
||||
|
||||
it('derives legacy percentage when the response percentage overflows', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":5000,"utilization":1e309,"currency":"EUR","is_enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: null,
|
||||
spent: 50,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 2.5
|
||||
})
|
||||
})
|
||||
|
||||
it('does not turn an overflowing legacy spend into a known amount', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":1e309,"currency":"EUR","is_enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: null,
|
||||
spent: null,
|
||||
spendLimit: 2000,
|
||||
spentPercent: null
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['null', '1e309'])('omits a legacy cap whose amount is %s', async (limit) => {
|
||||
const result = await fetchUsageResponse(
|
||||
`{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":${limit},"used_credits":5000,"currency":"EUR","is_enabled":true}}`
|
||||
)
|
||||
expect(result.extraUsage ?? null).toBeNull()
|
||||
})
|
||||
|
||||
it.each([-1, 0.5, 309])(
|
||||
'omits malformed legacy precision %s rather than displaying zero',
|
||||
async (exponent) => {
|
||||
const result = await fetchUsageResponse(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 10 },
|
||||
extra_usage: {
|
||||
monthly_limit: 200000,
|
||||
used_credits: 5000,
|
||||
decimal_places: exponent,
|
||||
currency: 'EUR',
|
||||
is_enabled: true
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.extraUsage ?? null).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('shows the spend cap as the monthly window when the plan has no 5h or 7d window', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
JSON.stringify({
|
||||
five_hour: null,
|
||||
seven_day: null,
|
||||
limits: [],
|
||||
spend: {
|
||||
used: { amount_minor: 40000, currency: 'USD', exponent: 2 },
|
||||
limit: { amount_minor: 50000, currency: 'USD', exponent: 2 },
|
||||
percent: 80,
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.session).toBeNull()
|
||||
expect(result.weekly).toBeNull()
|
||||
expect(result.monthly).toEqual({
|
||||
usedPercent: 80,
|
||||
windowMinutes: 43_200,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the monthly window from legacy extra_usage when spend is absent', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":null,"seven_day":null,"extra_usage":{"monthly_limit":50000,"used_credits":40000,"utilization":80,"currency":"USD","is_enabled":true}}'
|
||||
)
|
||||
expect(result.monthly).toMatchObject({ usedPercent: 80, windowMinutes: 43_200 })
|
||||
})
|
||||
|
||||
it('falls back to the legacy cap when spend carries only a balance', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
JSON.stringify({
|
||||
five_hour: null,
|
||||
seven_day: null,
|
||||
spend: { balance: { amount_minor: 1000, currency: 'USD' }, enabled: true },
|
||||
extra_usage: {
|
||||
monthly_limit: 50000,
|
||||
used_credits: 40000,
|
||||
utilization: 80,
|
||||
currency: 'USD',
|
||||
is_enabled: true
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({ balance: 10, spendLimit: null })
|
||||
expect(result.monthly).toMatchObject({ usedPercent: 80, windowMinutes: 43_200 })
|
||||
})
|
||||
|
||||
it('derives legacy percentage from the amounts when utilization is a fraction', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":null,"seven_day":null,"extra_usage":{"monthly_limit":5000,"used_credits":479,"utilization":0.0958,"currency":"USD","is_enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({ spentPercent: expect.closeTo(9.58) })
|
||||
expect(result.monthly?.usedPercent).toBeCloseTo(9.58)
|
||||
})
|
||||
|
||||
it('keeps spend out of the monthly window when a plan window exists', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":{"utilization":10},"seven_day":null,"spend":{"used":{"amount_minor":5000},"limit":{"amount_minor":200000},"percent":2.5,"enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({ spentPercent: 2.5 })
|
||||
expect(result.monthly).toBeNull()
|
||||
})
|
||||
|
||||
it('does not invent a monthly window from a balance without a cap', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":null,"seven_day":null,"spend":{"balance":{"amount_minor":1000,"currency":"EUR"},"enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({ balance: 10, spendLimit: null })
|
||||
expect(result.monthly).toBeNull()
|
||||
})
|
||||
|
||||
it('classifies only the request: a dead proxy is network, a null 200 body stays unknown', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('net::ERR_PROXY_CONNECTION_FAILED'))
|
||||
const transport = await fetchClaudeOAuthUsage('oauth-token').catch((error: unknown) => error)
|
||||
expect(classifyClaudeOAuthUsageError(transport).failureKind).toBe('network')
|
||||
|
||||
const nullBody = await fetchUsageResponse('null').catch((error: unknown) => error)
|
||||
expect(classifyClaudeOAuthUsageError(nullBody).failureKind).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -1,329 +0,0 @@
|
||||
import type * as Keychain from '../claude-accounts/keychain'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
const calls = vi.hoisted(() => ({ keychain: vi.fn(), usage: vi.fn(), keychainService: vi.fn() }))
|
||||
vi.mock('../macos-keychain/generic-password', () => ({
|
||||
readKeychainPassword: calls.keychainService
|
||||
}))
|
||||
vi.mock('../claude-accounts/keychain', () => ({
|
||||
readActiveClaudeKeychainCredentialsStrict: calls.keychain
|
||||
}))
|
||||
const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({}))
|
||||
const wslRouter = vi.hoisted(() => ({ accountUsagePreparation: vi.fn() }))
|
||||
vi.mock('../claude-accounts/claude-profile-installed-router', () => ({
|
||||
getClaudeProfileRouter: () =>
|
||||
profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined,
|
||||
getClaudeWslProfileRouter: () => wslRouter
|
||||
}))
|
||||
vi.mock('./claude-oauth-usage-request', () => ({ fetchClaudeOAuthUsage: calls.usage }))
|
||||
const runningDistros = vi.hoisted(() => ({
|
||||
filter: vi.fn(async (paths: readonly string[], _options?: { requireConfirmed?: boolean }) => [
|
||||
...paths
|
||||
])
|
||||
}))
|
||||
// The settled gate asks the running list for the path's distro; `filter` stands in for it.
|
||||
const settledGate = vi.hoisted(() => ({
|
||||
override: vi.fn((): { distro: string; state: 'stopped' } | undefined => undefined)
|
||||
}))
|
||||
vi.mock('../wsl/filesystem/wsl-unc-access-gate', () => ({
|
||||
blockedWslDistroForPath: async (path: string) => {
|
||||
const override = settledGate.override()
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
return (await runningDistros.filter([path], { requireConfirmed: true })).length > 0
|
||||
? null
|
||||
: { distro: 'Ubuntu', state: 'stopped' }
|
||||
}
|
||||
}))
|
||||
vi.mock('@orca/process-host', () => ({
|
||||
spawnProcess: () => {
|
||||
throw new Error('Usage must not launch a process')
|
||||
},
|
||||
runProcess: () => {
|
||||
throw new Error('Usage must not launch a process')
|
||||
}
|
||||
}))
|
||||
import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch'
|
||||
import {
|
||||
readClaudeOAuthCredentials,
|
||||
resolveClaudeOAuthCredentialReadOptions
|
||||
} from './claude-oauth-credentials'
|
||||
import { OAuthUsageError } from './claude-oauth-usage-error'
|
||||
import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing'
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
function profile() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'claude-usage-'))
|
||||
roots.push(home)
|
||||
calls.keychain.mockResolvedValue(null)
|
||||
const options = {
|
||||
authPreparation: {
|
||||
configDir: home,
|
||||
envPatch: { CLAUDE_CONFIG_DIR: home },
|
||||
provenance: 'profile:fake'
|
||||
}
|
||||
}
|
||||
return { home, options }
|
||||
}
|
||||
function systemDefault() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'claude-usage-default-'))
|
||||
roots.push(home)
|
||||
calls.keychain.mockResolvedValue(null)
|
||||
return {
|
||||
home,
|
||||
options: {
|
||||
authPreparation: { configDir: home, envPatch: {}, provenance: 'system' }
|
||||
}
|
||||
}
|
||||
}
|
||||
it('lets the server decide a locally expired token and never refreshes it', async () => {
|
||||
const f = profile()
|
||||
const file = join(f.home, '.credentials.json')
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'expired-fake',
|
||||
refreshToken: 'must-not-be-used',
|
||||
expiresAt: 1,
|
||||
refreshTokenExpiresAt: 4_102_444_800_000
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', () => {
|
||||
throw new Error('No refresh endpoint')
|
||||
})
|
||||
calls.usage.mockRejectedValue(new OAuthUsageError('Invalid OAuth token.', 401, true, null))
|
||||
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'Claude usage updates the next time Claude runs.',
|
||||
usageMetadata: { failureKind: 'stale-token' }
|
||||
})
|
||||
expect(calls.usage.mock.calls.map(([token]) => token)).toEqual(['expired-fake'])
|
||||
const system = systemDefault()
|
||||
writeFileSync(
|
||||
join(system.home, '.credentials.json'),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: { accessToken: 'expired-default', refreshToken: 'unused', expiresAt: 1 }
|
||||
})
|
||||
)
|
||||
calls.usage.mockResolvedValueOnce({ provider: 'claude', status: 'ok' })
|
||||
expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ status: 'ok' })
|
||||
expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({
|
||||
error: 'Claude usage updates the next time Claude runs.',
|
||||
usageMetadata: { failureKind: 'stale-token' }
|
||||
})
|
||||
})
|
||||
it.each([
|
||||
['no refresh token', {}, 'Invalid OAuth token.'],
|
||||
['an expired refresh token', { refreshToken: 'fake', refreshTokenExpiresAt: 1 }, 'Invalid'],
|
||||
['a revoked refresh token', { refreshToken: 'fake' }, 'OAuth token has been revoked.']
|
||||
])('asks a login with %s to sign in again instead of waiting for Claude', async (_, oauth, msg) => {
|
||||
const f = profile()
|
||||
writeFileSync(
|
||||
join(f.home, '.credentials.json'),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: 'expired-fake', expiresAt: 1, ...oauth } })
|
||||
)
|
||||
calls.usage.mockRejectedValue(new OAuthUsageError(msg, 401, true, null))
|
||||
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'Sign in again to use this account.',
|
||||
usageMetadata: { failureKind: 'missing-credentials' }
|
||||
})
|
||||
})
|
||||
it('reads only the requested scoped Keychain, with no unsuffixed fallback', async () => {
|
||||
const f = profile()
|
||||
await readClaudeOAuthCredentials({ credentialsFileConfigDir: f.home, keychainConfigDir: f.home })
|
||||
if (process.platform === 'darwin') {
|
||||
expect(calls.keychain.mock.calls).toEqual([[f.home]])
|
||||
} else {
|
||||
expect(calls.keychain).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
it('distinguishes missing, malformed and inaccessible credential observations', async () => {
|
||||
const f = profile()
|
||||
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
|
||||
usageMetadata: { failureKind: 'missing-credentials' }
|
||||
})
|
||||
writeFileSync(join(f.home, '.credentials.json'), '{')
|
||||
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
|
||||
usageMetadata: { failureKind: 'keychain-unavailable' }
|
||||
})
|
||||
})
|
||||
it('reports an unreadable selected account before any credential read and requests no hidden recovery', async () => {
|
||||
const f = profile()
|
||||
expect(
|
||||
await fetchActiveClaudeRateLimits({
|
||||
authPreparation: { ...f.options.authPreparation, usageError: CLAUDE_PROFILE_MISSING_MESSAGE }
|
||||
})
|
||||
).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'missing-credentials' } })
|
||||
expect(calls.keychain).not.toHaveBeenCalled()
|
||||
writeFileSync(
|
||||
join(f.home, '.credentials.json'),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } })
|
||||
)
|
||||
calls.usage.mockRejectedValue(new Error('network unavailable'))
|
||||
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ status: 'error' })
|
||||
expect(calls.usage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('strict Keychain lookup never falls back to the unsuffixed service', async () => {
|
||||
const f = profile()
|
||||
calls.keychainService.mockResolvedValue(null)
|
||||
const actual = await vi.importActual<typeof Keychain>('../claude-accounts/keychain')
|
||||
await actual.readActiveClaudeKeychainCredentialsStrict(f.home)
|
||||
expect(calls.keychainService).toHaveBeenCalled()
|
||||
for (const [service] of calls.keychainService.mock.calls) {
|
||||
expect(service).toMatch(/^Claude Code-credentials-[0-9a-f]{8}$/)
|
||||
}
|
||||
calls.keychainService.mockClear()
|
||||
calls.keychainService.mockRejectedValue(new Error('locked'))
|
||||
await expect(actual.readActiveClaudeKeychainCredentialsStrict(f.home)).rejects.toThrow('locked')
|
||||
expect(calls.keychainService).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('treats an unreadable credential file as unavailable, not a missing login', async () => {
|
||||
const f = profile()
|
||||
mkdirSync(join(f.home, '.credentials.json'))
|
||||
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
|
||||
usageMetadata: { failureKind: 'keychain-unavailable' }
|
||||
})
|
||||
})
|
||||
it('hides Claude usage for System Default with no Claude login, instead of asking to sign in again', async () => {
|
||||
const f = systemDefault()
|
||||
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
|
||||
status: 'unavailable',
|
||||
usageMetadata: { failureKind: 'missing-credentials' }
|
||||
})
|
||||
expect(await fetchActiveClaudeRateLimits(profile().options)).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'Sign in again to use this account.'
|
||||
})
|
||||
})
|
||||
it("keeps a 429's Retry-After and rate-limit message so polling waits it out", async () => {
|
||||
const f = profile()
|
||||
writeFileSync(
|
||||
join(f.home, '.credentials.json'),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } })
|
||||
)
|
||||
const message = 'Claude usage is rate limited right now.'
|
||||
calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, 3_000_000))
|
||||
const before = Date.now()
|
||||
const limited = await fetchActiveClaudeRateLimits(f.options)
|
||||
expect(limited).toMatchObject({
|
||||
status: 'error',
|
||||
error: message,
|
||||
usageMetadata: { failureKind: 'rate-limited' }
|
||||
})
|
||||
expect(limited.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3_000_000)
|
||||
expect(limited.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3_000_000)
|
||||
calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, null))
|
||||
expect((await fetchActiveClaudeRateLimits(f.options)).usageMetadata?.retryAtMs).toBeUndefined()
|
||||
})
|
||||
it("reads System default's own CLAUDE_CONFIG_DIR Keychain item before the unsuffixed one", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'claude-usage-inherited-'))
|
||||
roots.push(root)
|
||||
const inherited = join(root, 'own-claude-config')
|
||||
vi.stubEnv('CLAUDE_CONFIG_DIR', inherited)
|
||||
try {
|
||||
calls.keychain.mockResolvedValue(null)
|
||||
await readClaudeOAuthCredentials(
|
||||
resolveClaudeOAuthCredentialReadOptions({
|
||||
configDir: inherited,
|
||||
envPatch: {},
|
||||
provenance: 'system'
|
||||
})
|
||||
)
|
||||
expect(calls.keychain.mock.calls).toEqual(
|
||||
process.platform === 'darwin' ? [[inherited], [undefined]] : []
|
||||
)
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
const managed = profile()
|
||||
calls.keychain.mockClear()
|
||||
await fetchActiveClaudeRateLimits(managed.options)
|
||||
expect(calls.keychain.mock.calls).toEqual(process.platform === 'darwin' ? [[managed.home]] : [])
|
||||
})
|
||||
it("names System default's Keychain item from the login shell's CLAUDE_CONFIG_DIR a Dock launch lacks", () => {
|
||||
vi.stubEnv('CLAUDE_CONFIG_DIR', '')
|
||||
profileRouter.userConfigDir = '/shell/claude-config'
|
||||
try {
|
||||
expect(
|
||||
resolveClaudeOAuthCredentialReadOptions({
|
||||
configDir: '/shell/claude-config',
|
||||
envPatch: {},
|
||||
provenance: 'system'
|
||||
})?.keychainConfigDir
|
||||
).toBe('/shell/claude-config')
|
||||
} finally {
|
||||
profileRouter.userConfigDir = undefined
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
it('reports a host problem as unavailable usage, never as a signed-out account', async () => {
|
||||
const f = profile()
|
||||
expect(
|
||||
await fetchActiveClaudeRateLimits({
|
||||
authPreparation: {
|
||||
...f.options.authPreparation,
|
||||
usageError: 'Could not read the home folder of WSL distro Ubuntu.'
|
||||
}
|
||||
})
|
||||
).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'usage-unavailable' } })
|
||||
})
|
||||
it('reads an inactive WSL account where the WSL router says its launches would run', async () => {
|
||||
const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage')
|
||||
const authPreparation = {
|
||||
configDir: '\\\\wsl.localhost\\Ubuntu\\home\\u\\.claude',
|
||||
runtime: 'wsl' as const,
|
||||
wslDistro: 'Ubuntu',
|
||||
envPatch: {},
|
||||
provenance: 'wsl:Ubuntu:system'
|
||||
}
|
||||
wslRouter.accountUsagePreparation.mockResolvedValueOnce(authPreparation)
|
||||
calls.usage.mockResolvedValueOnce({ five_hour: null, seven_day: null })
|
||||
await fetchInactiveClaudeAccountUsage({
|
||||
id: 'acct',
|
||||
managedAuthRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/acct/auth'
|
||||
})
|
||||
expect(wslRouter.accountUsagePreparation).toHaveBeenCalledWith('Ubuntu', 'acct')
|
||||
})
|
||||
it('does not read an inactive WSL account a running list still names while it stops (E9)', async () => {
|
||||
const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage')
|
||||
settledGate.override.mockReturnValueOnce({ distro: 'Ubuntu', state: 'stopped' })
|
||||
wslRouter.accountUsagePreparation.mockClear()
|
||||
const result = await fetchInactiveClaudeAccountUsage({
|
||||
id: 'acct',
|
||||
managedAuthRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/acct/home'
|
||||
})
|
||||
expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } })
|
||||
expect(wslRouter.accountUsagePreparation).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not read an inactive WSL account through a stopped distro', async () => {
|
||||
const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage')
|
||||
runningDistros.filter.mockResolvedValueOnce([])
|
||||
wslRouter.accountUsagePreparation.mockClear()
|
||||
const result = await fetchInactiveClaudeAccountUsage({
|
||||
id: 'acct',
|
||||
managedAuthRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/acct/home'
|
||||
})
|
||||
expect(runningDistros.filter).toHaveBeenCalledWith(['\\\\wsl.localhost\\Ubuntu\\'], {
|
||||
requireConfirmed: true
|
||||
})
|
||||
expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } })
|
||||
expect(wslRouter.accountUsagePreparation).not.toHaveBeenCalled()
|
||||
expect(calls.keychain).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,353 +0,0 @@
|
||||
import { join } from 'node:path'
|
||||
import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setMainHttpClient } from '../network/http-client'
|
||||
|
||||
const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({
|
||||
childSpawnMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
ptySpawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
// Keep child-process assertions in this suite; worker transport is covered separately.
|
||||
vi.mock(
|
||||
'../worker-thread-child-process/worker-thread-child-process',
|
||||
() =>
|
||||
import('../worker-thread-child-process/worker-thread-child-process-in-process.test-fixtures')
|
||||
)
|
||||
vi.mock('node:child_process', () => ({ spawn: childSpawnMock }))
|
||||
vi.mock('node:fs/promises', () => ({ readFile: readFileMock }))
|
||||
vi.mock('node-pty', () => ({ spawn: ptySpawnMock }))
|
||||
vi.mock('./codex-auth-presence', () => ({
|
||||
probeCodexAuthPresence: vi.fn(async () => 'present')
|
||||
}))
|
||||
|
||||
// Mock recovery so its process-table import does not require real execFile.
|
||||
vi.mock('../codex/history/index/codex-state-db-backfill-recovery', () => ({
|
||||
startCodexStateDbBackfillRecoveryInBackground: vi.fn()
|
||||
}))
|
||||
|
||||
import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher'
|
||||
|
||||
describe('Codex backend rate-limit requests', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setMainHttpClient(null)
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses the host HTTP client for usage, credit reads and redemption', async () => {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
tokens: { access_token: 'synthetic-token', account_id: 'synthetic-account' }
|
||||
})
|
||||
)
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
Response.json({
|
||||
plan_type: 'plus',
|
||||
rate_limit: { primary_window: { used_percent: 12, limit_window_seconds: 3_600 } }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(Response.json({ available_count: 0, credits: [] }))
|
||||
.mockResolvedValueOnce(Response.json({ code: 'already_redeemed' }))
|
||||
setMainHttpClient({ fetch: request, proxySession: () => null })
|
||||
const deadline = vi.spyOn(AbortSignal, 'timeout')
|
||||
|
||||
await expect(
|
||||
fetchCodexRateLimits({ codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\fixture\\.codex' })
|
||||
).resolves.toMatchObject({ status: 'ok', session: { usedPercent: 12 } })
|
||||
await expect(
|
||||
consumeCodexRateLimitResetCredit({
|
||||
codexHomePath: '/managed/synthetic-home',
|
||||
idempotencyKey: 'synthetic-request'
|
||||
})
|
||||
).resolves.toBe('alreadyRedeemed')
|
||||
|
||||
expect(request.mock.calls.map(([url]) => url)).toEqual([
|
||||
'https://chatgpt.com/backend-api/wham/usage',
|
||||
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits',
|
||||
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume'
|
||||
])
|
||||
for (const [, init] of request.mock.calls) {
|
||||
expect(init).toEqual(
|
||||
expect.objectContaining({
|
||||
credentials: 'omit',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer synthetic-token',
|
||||
'ChatGPT-Account-Id': 'synthetic-account'
|
||||
}),
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
}
|
||||
expect(request).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ redeem_request_id: 'synthetic-request' }),
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' })
|
||||
})
|
||||
)
|
||||
expect(deadline.mock.calls.map(([milliseconds]) => milliseconds)).toEqual([
|
||||
10_000, 10_000, 30_000
|
||||
])
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fall back to global fetch when the configured host transport fails', async () => {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
tokens: { access_token: 'synthetic-token', account_id: 'synthetic-account' }
|
||||
})
|
||||
)
|
||||
const failure = new Error('fixture proxy refused the connection')
|
||||
const request = vi.fn().mockRejectedValue(failure)
|
||||
setMainHttpClient({ fetch: request, proxySession: () => null })
|
||||
await expect(
|
||||
consumeCodexRateLimitResetCredit({
|
||||
codexHomePath: '/managed/synthetic-home',
|
||||
idempotencyKey: 'synthetic-request'
|
||||
})
|
||||
).rejects.toBe(failure)
|
||||
expect(request).toHaveBeenCalledOnce()
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the official backend usage contract without spawning Codex', async () => {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
tokens: { access_token: 'access-token', account_id: 'account-id' }
|
||||
})
|
||||
)
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
plan_type: 'plus',
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 12,
|
||||
limit_window_seconds: 3_600,
|
||||
reset_at: 1_800_000_000
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: 34,
|
||||
limit_window_seconds: 86_400,
|
||||
reset_at: 1_800_100_000
|
||||
}
|
||||
},
|
||||
rate_limit_reset_credits: { available_count: 1 }
|
||||
})
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
available_count: 1,
|
||||
credits: [
|
||||
{
|
||||
status: 'available',
|
||||
expires_at: '2027-01-15T12:00:00Z',
|
||||
granted_at: '2027-01-08T12:00:00Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response)
|
||||
|
||||
await expect(
|
||||
fetchCodexRateLimits({
|
||||
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
session: { usedPercent: 12, windowMinutes: 60, resetsAt: 1_800_000_000_000 },
|
||||
weekly: { usedPercent: 34, windowMinutes: 1440, resetsAt: 1_800_100_000_000 },
|
||||
rateLimitResetCredits: {
|
||||
availableCount: 1,
|
||||
nextExpiresAt: Date.parse('2027-01-15T12:00:00Z')
|
||||
},
|
||||
planType: 'plus',
|
||||
status: 'ok'
|
||||
})
|
||||
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://chatgpt.com/backend-api/wham/usage',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer access-token' }),
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies a sole seven-day backend primary window as weekly', async () => {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
tokens: { access_token: 'access-token', account_id: 'account-id' }
|
||||
})
|
||||
)
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
plan_type: 'plus',
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 37,
|
||||
limit_window_seconds: 7 * 24 * 60 * 60,
|
||||
reset_at: 1_800_000_000
|
||||
},
|
||||
secondary_window: null
|
||||
}
|
||||
})
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ available_count: 0, credits: [] })
|
||||
} as Response)
|
||||
|
||||
await expect(
|
||||
fetchCodexRateLimits({
|
||||
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
session: null,
|
||||
weekly: { usedPercent: 37, windowMinutes: 10_080, resetsAt: 1_800_000_000_000 },
|
||||
status: 'ok'
|
||||
})
|
||||
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts callers while sharing one stalled backend auth read', async () => {
|
||||
let resolveRead!: (content: string) => void
|
||||
readFileMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
)
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
const codexHomePath = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
|
||||
|
||||
const first = fetchCodexRateLimits({ codexHomePath, signal: firstController.signal })
|
||||
const second = fetchCodexRateLimits({ codexHomePath, signal: secondController.signal })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json'), 'utf8')
|
||||
|
||||
firstController.abort()
|
||||
secondController.abort()
|
||||
|
||||
await expect(first).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
await expect(second).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
resolveRead('{}')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
it('applies the backend deadline while auth reading is still pending', async () => {
|
||||
let resolveRead!: (content: string) => void
|
||||
readFileMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
)
|
||||
const timeoutController = new AbortController()
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(timeoutController.signal)
|
||||
const deadlineError = new Error('backend deadline')
|
||||
|
||||
const result = consumeCodexRateLimitResetCredit({
|
||||
codexHomePath: '/managed/deadline-home',
|
||||
idempotencyKey: 'redeem-timeout'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// User-triggered redemption has the longer deadline.
|
||||
expect(timeout).toHaveBeenCalledWith(30_000)
|
||||
expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json'), 'utf8')
|
||||
|
||||
timeoutController.abort(deadlineError)
|
||||
|
||||
await expect(result).rejects.toBe(deadlineError)
|
||||
resolveRead('{}')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
it('consumes a reset credit with the official payload and bounded request signal', async () => {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
tokens: { access_token: 'access-token', account_id: 'account-id' }
|
||||
})
|
||||
)
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ code: 'already_redeemed' })
|
||||
} as Response)
|
||||
|
||||
await expect(
|
||||
consumeCodexRateLimitResetCredit({
|
||||
codexHomePath: '/managed/codex-home',
|
||||
idempotencyKey: 'redeem-123'
|
||||
})
|
||||
).resolves.toBe('alreadyRedeemed')
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ redeem_request_id: 'redeem-123' }),
|
||||
signal: expect.any(AbortSignal),
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer access-token',
|
||||
'ChatGPT-Account-Id': 'account-id',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels the unread error-response body so bundled undici cannot crash on socket close', async () => {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
tokens: { access_token: 'access-token', account_id: 'account-id' }
|
||||
})
|
||||
)
|
||||
let cancelledBodies = 0
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
cancelTrackingResponse(429, () => {
|
||||
cancelledBodies += 1
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
consumeCodexRateLimitResetCredit({
|
||||
codexHomePath: '/managed/codex-home',
|
||||
idempotencyKey: 'redeem-429'
|
||||
})
|
||||
).rejects.toThrow('Codex reset failed: HTTP 429')
|
||||
expect(cancelledBodies).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,382 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({
|
||||
childSpawnMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
resolveCodexCommandMock: vi.fn(),
|
||||
ptySpawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
// Keep child-process assertions in this suite; worker transport is covered separately.
|
||||
vi.mock(
|
||||
'../worker-thread-child-process/worker-thread-child-process',
|
||||
() =>
|
||||
import('../worker-thread-child-process/worker-thread-child-process-in-process.test-fixtures')
|
||||
)
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: childSpawnMock
|
||||
}))
|
||||
|
||||
// Mock recovery so its process-table import does not require real execFile.
|
||||
vi.mock('../codex/history/index/codex-state-db-backfill-recovery', () => ({
|
||||
startCodexStateDbBackfillRecoveryInBackground: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: readFileMock
|
||||
}))
|
||||
|
||||
vi.mock('../codex-cli/command', () => ({
|
||||
resolveCodexCommand: resolveCodexCommandMock
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: ptySpawnMock
|
||||
}))
|
||||
|
||||
vi.mock('./codex-auth-presence', () => ({
|
||||
probeCodexAuthPresence: vi.fn(() => 'present')
|
||||
}))
|
||||
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { probeCodexAuthPresence } from './codex-auth-presence'
|
||||
|
||||
function makeRpcChild() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
stdin: EventEmitter & { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> }
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
exitCode: number | null
|
||||
}
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
// Graceful shutdown must wait for EOF/signal to produce exit, as real app-server does.
|
||||
const exitNow = (): void => {
|
||||
child.exitCode = 0
|
||||
child.emit('exit', 0, null)
|
||||
}
|
||||
child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(exitNow) })
|
||||
child.exitCode = null
|
||||
child.kill = vi.fn(() => {
|
||||
exitNow()
|
||||
return true
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
function respondToRpcRateLimitRead(
|
||||
rpcChild: ReturnType<typeof makeRpcChild>,
|
||||
rateLimits: unknown
|
||||
): void {
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
if (msg.method === 'account/rateLimits/read') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { rateLimits } })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchCodexRateLimits probe shutdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
resolveCodexCommandMock.mockReturnValue('codex')
|
||||
vi.mocked(probeCodexAuthPresence).mockResolvedValue('present')
|
||||
readFileMock.mockRejectedValue(new Error('no auth fixture'))
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('gives a cold app-server the full boot budget before any deadline fires', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
// Cold auth startup takes 10–25s; the old 10s cap interrupted refresh.
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 20_000)
|
||||
}
|
||||
if (msg.method === 'account/rateLimits/read') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: {
|
||||
rateLimits: { primary: { usedPercent: 3 }, secondary: { usedPercent: 4 } }
|
||||
}
|
||||
})}\n`
|
||||
)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(19_999)
|
||||
expect(rpcChild.kill).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
session: { usedPercent: 3 },
|
||||
weekly: { usedPercent: 4 }
|
||||
})
|
||||
})
|
||||
|
||||
it('arms the read deadline after initialize and drains SIGTERM before SIGKILL', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
const rpcChild = makeRpcChild()
|
||||
// Force escalation: ignore EOF/SIGTERM, exit only on SIGKILL.
|
||||
rpcChild.stdin.end = vi.fn()
|
||||
rpcChild.kill = vi.fn((signal?: string) => {
|
||||
if (signal === 'SIGKILL') {
|
||||
rpcChild.exitCode = 0
|
||||
rpcChild.emit('exit', 0, null)
|
||||
}
|
||||
return true
|
||||
})
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(9_999)
|
||||
expect(rpcChild.kill).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(rpcChild.stdin.end).toHaveBeenCalledTimes(1)
|
||||
expect(rpcChild.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(rpcChild.kill).not.toHaveBeenCalledWith('SIGKILL')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(rpcChild.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
status: 'error',
|
||||
error: 'RPC timeout'
|
||||
})
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not spawn a second probe for the same home while one is still running', async () => {
|
||||
const firstChild = makeRpcChild()
|
||||
const secondChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild)
|
||||
respondToRpcRateLimitRead(secondChild, {
|
||||
primary: { usedPercent: 3 },
|
||||
secondary: { usedPercent: 4 }
|
||||
})
|
||||
|
||||
const first = fetchCodexRateLimits({
|
||||
codexHomePath: '/managed/home-a'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const second = fetchCodexRateLimits({
|
||||
codexHomePath: '/managed/home-a'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstChild.emit('close')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(2)
|
||||
await expect(second).resolves.toMatchObject({ status: 'ok' })
|
||||
await first
|
||||
})
|
||||
|
||||
it('keeps the home lock through child errors until the failed probe exits', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
const firstChild = makeRpcChild()
|
||||
firstChild.stdin.end = vi.fn()
|
||||
firstChild.kill = vi.fn((signal?: string) => {
|
||||
if (signal === 'SIGKILL') {
|
||||
firstChild.exitCode = 1
|
||||
firstChild.emit('exit', 1, 'SIGKILL')
|
||||
}
|
||||
return true
|
||||
})
|
||||
const secondChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild)
|
||||
respondToRpcRateLimitRead(secondChild, {
|
||||
primary: { usedPercent: 3 },
|
||||
secondary: { usedPercent: 4 }
|
||||
})
|
||||
|
||||
try {
|
||||
const first = fetchCodexRateLimits({
|
||||
codexHomePath: '/managed/home-error'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
firstChild.emit('error', new Error('stdio failed'))
|
||||
const second = fetchCodexRateLimits({
|
||||
codexHomePath: '/managed/home-error'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(firstChild.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await expect(first).resolves.toMatchObject({ status: 'error', error: 'stdio failed' })
|
||||
await expect(second).resolves.toMatchObject({ status: 'ok' })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failAt: 1, phase: 'initial request' },
|
||||
{ failAt: 2, phase: 'initialized notification' },
|
||||
{ failAt: 3, phase: 'rate-limit request' }
|
||||
])('keeps the home lock when the $phase stdin write throws', async ({ failAt, phase }) => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
const firstChild = makeRpcChild()
|
||||
let writeCount = 0
|
||||
firstChild.stdin.write.mockImplementation((line: string) => {
|
||||
writeCount += 1
|
||||
if (writeCount === failAt) {
|
||||
throw new Error(`${phase} stdin failed`)
|
||||
}
|
||||
const message = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (message.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
firstChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
firstChild.stdin.end = vi.fn()
|
||||
firstChild.kill = vi.fn((signal?: string) => {
|
||||
if (signal === 'SIGKILL') {
|
||||
firstChild.exitCode = 1
|
||||
firstChild.emit('exit', 1, 'SIGKILL')
|
||||
}
|
||||
return true
|
||||
})
|
||||
const secondChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild)
|
||||
respondToRpcRateLimitRead(secondChild, {
|
||||
primary: { usedPercent: 3 },
|
||||
secondary: { usedPercent: 4 }
|
||||
})
|
||||
const home = `/managed/home-stdin-error-${failAt}`
|
||||
|
||||
try {
|
||||
const first = fetchCodexRateLimits({
|
||||
codexHomePath: home
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(firstChild.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
|
||||
const second = fetchCodexRateLimits({
|
||||
codexHomePath: home
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(4_999)
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await expect(first).resolves.toMatchObject({
|
||||
status: 'error',
|
||||
error: `${phase} stdin failed`
|
||||
})
|
||||
await expect(second).resolves.toMatchObject({ status: 'ok' })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('observes asynchronous stdin failures and keeps the lock until the probe exits', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
const firstChild = makeRpcChild()
|
||||
firstChild.stdin.end = vi.fn()
|
||||
firstChild.kill = vi.fn((signal?: string) => {
|
||||
if (signal === 'SIGKILL') {
|
||||
firstChild.exitCode = 1
|
||||
firstChild.emit('exit', 1, 'SIGKILL')
|
||||
}
|
||||
return true
|
||||
})
|
||||
const secondChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild)
|
||||
respondToRpcRateLimitRead(secondChild, {
|
||||
primary: { usedPercent: 3 },
|
||||
secondary: { usedPercent: 4 }
|
||||
})
|
||||
|
||||
try {
|
||||
const first = fetchCodexRateLimits({
|
||||
codexHomePath: '/managed/home-async-stdin-error'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
firstChild.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
|
||||
const second = fetchCodexRateLimits({
|
||||
codexHomePath: '/managed/home-async-stdin-error'
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999)
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await expect(first).resolves.toMatchObject({ status: 'error', error: 'write EPIPE' })
|
||||
await expect(second).resolves.toMatchObject({ status: 'ok' })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,818 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as RunProcess from '@orca/process-host'
|
||||
|
||||
const {
|
||||
childSpawnMock,
|
||||
readFileMock,
|
||||
resolveCodexCommandMock,
|
||||
ptySpawnMock,
|
||||
isBackfillPendingMock,
|
||||
startBackfillRecoveryMock,
|
||||
backfillGaveUpMock
|
||||
} = vi.hoisted(() => ({
|
||||
childSpawnMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
resolveCodexCommandMock: vi.fn(),
|
||||
ptySpawnMock: vi.fn(),
|
||||
isBackfillPendingMock: vi.fn(() => false),
|
||||
startBackfillRecoveryMock: vi.fn(() => Promise.resolve(null)),
|
||||
backfillGaveUpMock: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
// Keep child-process assertions in this suite; worker transport is covered separately.
|
||||
vi.mock(
|
||||
'../worker-thread-child-process/worker-thread-child-process',
|
||||
() =>
|
||||
import('../worker-thread-child-process/worker-thread-child-process-in-process.test-fixtures')
|
||||
)
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: childSpawnMock
|
||||
}))
|
||||
|
||||
// Observe spawn requests before npm-shim resolution.
|
||||
vi.mock('@orca/process-host', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof RunProcess>()),
|
||||
spawnProcess: (spec: { program: string; args?: readonly string[] }) =>
|
||||
childSpawnMock(spec.program, spec.args ?? [], spec)
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: readFileMock
|
||||
}))
|
||||
|
||||
vi.mock('../codex-cli/command', () => ({
|
||||
resolveCodexCommand: resolveCodexCommandMock
|
||||
}))
|
||||
|
||||
vi.mock('../wsl/execution/wsl-interop-spawn-directory', () => ({
|
||||
resolveWslInteropSpawnCwd: () => 'C:\\Windows'
|
||||
}))
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: ptySpawnMock
|
||||
}))
|
||||
|
||||
vi.mock('../codex/history/index/codex-state-db', () => ({
|
||||
isCodexStateDbBackfillPending: isBackfillPendingMock
|
||||
}))
|
||||
|
||||
vi.mock('../codex/history/index/codex-state-db-backfill-recovery', () => ({
|
||||
startCodexStateDbBackfillRecoveryInBackground: startBackfillRecoveryMock,
|
||||
hasCodexStateDbBackfillRecoveryGivenUp: backfillGaveUpMock
|
||||
}))
|
||||
|
||||
// Assume signed-in for spawn tests; codex-auth-presence.test.ts covers gating.
|
||||
vi.mock('./codex-auth-presence', () => ({
|
||||
probeCodexAuthPresence: vi.fn(() => 'present')
|
||||
}))
|
||||
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { probeCodexAuthPresence } from './codex-auth-presence'
|
||||
import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
|
||||
|
||||
function makeRpcChild() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
stdin: EventEmitter & { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> }
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
exitCode: number | null
|
||||
}
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
// Graceful shutdown must wait for EOF/signal to produce exit, as real app-server does.
|
||||
const exitNow = (): void => {
|
||||
child.exitCode = 0
|
||||
child.emit('exit', 0, null)
|
||||
child.emit('close', 0, null)
|
||||
}
|
||||
child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(exitNow) })
|
||||
child.exitCode = null
|
||||
child.kill = vi.fn(() => {
|
||||
exitNow()
|
||||
return true
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
function respondToRpcRateLimitRead(
|
||||
rpcChild: ReturnType<typeof makeRpcChild>,
|
||||
rateLimits: unknown
|
||||
): void {
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
if (msg.method === 'account/rateLimits/read') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { rateLimits } })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mockBackendUsage(): void {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } })
|
||||
)
|
||||
// Response bodies are single-use; usage and credit reset need separate fixtures.
|
||||
vi.mocked(fetch).mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
plan_type: 'plus',
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 7, limit_window_seconds: 5 * 60 * 60 },
|
||||
secondary_window: { used_percent: 12, limit_window_seconds: 7 * 24 * 60 * 60 }
|
||||
},
|
||||
rate_limit_reset_credits: { available_count: 0, credits: [] }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
describe('fetchCodexRateLimits', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
resolveCodexCommandMock.mockReturnValue('codex')
|
||||
vi.mocked(probeCodexAuthPresence).mockResolvedValue('present')
|
||||
readFileMock.mockRejectedValue(new Error('no auth fixture'))
|
||||
isBackfillPendingMock.mockReturnValue(false)
|
||||
backfillGaveUpMock.mockReturnValue(false)
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not spawn Codex when the user is not signed in', async () => {
|
||||
vi.mocked(probeCodexAuthPresence).mockResolvedValue('absent')
|
||||
|
||||
await expect(fetchCodexRateLimits()).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
status: 'unavailable',
|
||||
error: 'Codex not signed in'
|
||||
})
|
||||
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let a quota probe steal an incomplete state-DB backfill lease', async () => {
|
||||
isBackfillPendingMock.mockReturnValue(true)
|
||||
|
||||
await expect(fetchCodexRateLimits({ codexHomePath: '/managed-home' })).resolves.toMatchObject({
|
||||
status: 'error',
|
||||
error: expect.stringContaining('session index')
|
||||
})
|
||||
expect(startBackfillRecoveryMock).toHaveBeenCalledWith('/managed-home')
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads HTTP usage while a state-DB backfill is pending', async () => {
|
||||
isBackfillPendingMock.mockReturnValue(true)
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } })
|
||||
)
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
Response.json({
|
||||
plan_type: 'plus',
|
||||
rate_limit: { primary_window: { used_percent: 12, limit_window_seconds: 3_600 } }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(fetchCodexRateLimits({ codexHomePath: '/managed-home' })).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
session: { usedPercent: 12 }
|
||||
})
|
||||
expect(startBackfillRecoveryMock).toHaveBeenCalledWith('/managed-home')
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops promising a refresh once backfill recovery has given up', async () => {
|
||||
isBackfillPendingMock.mockReturnValue(true)
|
||||
backfillGaveUpMock.mockReturnValue(true)
|
||||
|
||||
const result = await fetchCodexRateLimits({ codexHomePath: '/managed-home' })
|
||||
|
||||
expect(result.error).toContain('could not rebuild')
|
||||
expect(result.error).toContain('CODEX_HOME')
|
||||
})
|
||||
|
||||
it('preserves the aborted result when cancellation lands during the auth check', async () => {
|
||||
let resolveAuth!: (presence: 'absent') => void
|
||||
vi.mocked(probeCodexAuthPresence).mockReturnValueOnce(
|
||||
new Promise<'absent'>((resolve) => {
|
||||
resolveAuth = resolve
|
||||
})
|
||||
)
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = fetchCodexRateLimits({ signal: controller.signal })
|
||||
controller.abort()
|
||||
resolveAuth('absent')
|
||||
|
||||
await expect(result).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['timeout', 'Timed out while checking Codex sign-in status'],
|
||||
['unavailable', 'Codex sign-in status is unavailable']
|
||||
] as const)(
|
||||
'does not report an indeterminate %s probe as signed out',
|
||||
async (presence, error) => {
|
||||
vi.mocked(probeCodexAuthPresence).mockResolvedValue(presence)
|
||||
|
||||
await expect(fetchCodexRateLimits()).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error
|
||||
})
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string
|
||||
expect(spawnCwd).toContain('rate-limit-pty-cwd')
|
||||
expect(spawnCwd).not.toBe('/')
|
||||
expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
|
||||
|
||||
rpcChild.emit('close')
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('kills the RPC child and skips the HTTP fallback when the fetch signal aborts', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
const controller = new AbortController()
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(rpcChild.kill).toHaveBeenCalledTimes(1)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads usage over HTTP, never through the Codex TUI, when RPC exits before returning usage', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
mockBackendUsage()
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
rpcChild.emit('close')
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
session: { usedPercent: 7, windowMinutes: 300 },
|
||||
weekly: { usedPercent: 12, windowMinutes: 10080 },
|
||||
status: 'ok',
|
||||
error: null
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://chatgpt.com/backend-api/wham/usage',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer access-token' })
|
||||
})
|
||||
)
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the RPC error when the HTTP fallback has no usage to offer', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
rpcChild.emit('close')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
status: 'error'
|
||||
})
|
||||
expect(rpcChild.stdin.listenerCount('error')).toBe(0)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the RPC exit reason when the HTTP fallback is rejected', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
mockBackendUsage()
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 }))
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
rpcChild.emit('close', 1, null)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
status: 'error',
|
||||
error: expect.stringContaining('exit code 1')
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes RPC listeners when the app-server timeout settles', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
// No initialize response means only the boot deadline can settle this request.
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
status: 'error',
|
||||
error: 'RPC timeout'
|
||||
})
|
||||
expect(rpcChild.kill).toHaveBeenCalledTimes(1)
|
||||
expect(rpcChild.stdout.listenerCount('data')).toBe(0)
|
||||
expect(rpcChild.stderr.listenerCount('data')).toBe(0)
|
||||
expect(rpcChild.listenerCount('error')).toBe(0)
|
||||
expect(rpcChild.listenerCount('close')).toBe(0)
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes near-canonical Codex RPC windows to fixed display durations', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
respondToRpcRateLimitRead(rpcChild, {
|
||||
primary: { usedPercent: 0, windowDurationMins: 299 },
|
||||
secondary: { usedPercent: 0, windowDurationMins: 10079 }
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result.session?.windowMinutes).toBe(300)
|
||||
expect(result.weekly?.windowMinutes).toBe(10080)
|
||||
})
|
||||
|
||||
it('keeps a weekly-only Codex primary window out of the 5-hour slot', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
respondToRpcRateLimitRead(rpcChild, {
|
||||
primary: { usedPercent: 22, windowDurationMins: 10080 },
|
||||
secondary: null
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: null,
|
||||
weekly: { usedPercent: 22, windowMinutes: 10080 }
|
||||
})
|
||||
})
|
||||
|
||||
it('fills a restored backend 5-hour window when RPC only reports weekly usage', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
respondToRpcRateLimitRead(rpcChild, {
|
||||
primary: { usedPercent: 22, windowDurationMins: 10080 },
|
||||
secondary: null
|
||||
})
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
tokens: { access_token: 'access-token', account_id: 'account-id' }
|
||||
})
|
||||
)
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
plan_type: 'plus',
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 7,
|
||||
limit_window_seconds: 5 * 60 * 60,
|
||||
reset_at: 1_800_000_000
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: 23,
|
||||
limit_window_seconds: 7 * 24 * 60 * 60,
|
||||
reset_at: 1_800_100_000
|
||||
}
|
||||
},
|
||||
rate_limit_reset_credits: {
|
||||
available_count: 1,
|
||||
credits: [{ status: 'available', expires_at: '2027-01-15T12:00:00Z' }]
|
||||
}
|
||||
})
|
||||
} as Response)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: { usedPercent: 7, windowMinutes: 300, resetsAt: 1_800_000_000_000 },
|
||||
weekly: { usedPercent: 23, windowMinutes: 10080, resetsAt: 1_800_100_000_000 },
|
||||
status: 'ok'
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not map a duplicate session-duration window into the weekly slot', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
respondToRpcRateLimitRead(rpcChild, {
|
||||
primary: { usedPercent: 11, windowDurationMins: 300 },
|
||||
secondary: { usedPercent: 12, windowDurationMins: 300 }
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: { usedPercent: 11, windowMinutes: 300 },
|
||||
weekly: null
|
||||
})
|
||||
})
|
||||
|
||||
it('fills reset-credit count from the backend when the installed app-server omits it', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
access_token: 'access-token',
|
||||
account_id: 'account-id'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
available_count: 2,
|
||||
total_earned_count: 3,
|
||||
credits: [
|
||||
{
|
||||
status: 'available',
|
||||
expires_at: '2026-06-25T12:00:00Z',
|
||||
granted_at: '2026-06-18T12:00:00Z'
|
||||
},
|
||||
{
|
||||
status: 'available',
|
||||
expires_at: '2026-06-24T12:00:00Z',
|
||||
granted_at: '2026-06-17T12:00:00Z'
|
||||
},
|
||||
{
|
||||
status: 'redeemed',
|
||||
expires_at: '2026-06-23T12:00:00Z',
|
||||
granted_at: '2026-06-16T12:00:00Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response)
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
if (msg.method === 'account/rateLimits/read') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: {
|
||||
rateLimits: {
|
||||
primary: { usedPercent: 3 },
|
||||
secondary: { usedPercent: 4 }
|
||||
}
|
||||
}
|
||||
})}\n`
|
||||
)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ codexHomePath: '/managed/codex-home' })
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result.rateLimitResetCredits).toEqual({
|
||||
availableCount: 2,
|
||||
totalEarnedCount: 3,
|
||||
nextExpiresAt: Date.parse('2026-06-24T12:00:00Z'),
|
||||
credits: [
|
||||
{
|
||||
status: 'available',
|
||||
expiresAt: Date.parse('2026-06-25T12:00:00Z'),
|
||||
grantedAt: Date.parse('2026-06-18T12:00:00Z')
|
||||
},
|
||||
{
|
||||
status: 'available',
|
||||
expiresAt: Date.parse('2026-06-24T12:00:00Z'),
|
||||
grantedAt: Date.parse('2026-06-17T12:00:00Z')
|
||||
},
|
||||
{
|
||||
status: 'redeemed',
|
||||
expiresAt: Date.parse('2026-06-23T12:00:00Z'),
|
||||
grantedAt: Date.parse('2026-06-16T12:00:00Z')
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(readFileMock).toHaveBeenCalledWith(join('/managed/codex-home', 'auth.json'), 'utf8')
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits',
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer access-token',
|
||||
'ChatGPT-Account-Id': 'account-id',
|
||||
'OpenAI-Beta': 'codex-1',
|
||||
originator: 'Codex Desktop'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses reset-credit count from newer app-server responses without backend fallback', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
if (msg.method === 'account/rateLimits/read') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: {
|
||||
rateLimits: { primary: { usedPercent: 5 } },
|
||||
rateLimitResetCredits: {
|
||||
availableCount: 1,
|
||||
credits: [
|
||||
{
|
||||
status: 'available',
|
||||
expiresAt: '1719326400',
|
||||
grantedAt: '1718721600000'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})}\n`
|
||||
)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result.rateLimitResetCredits).toEqual({
|
||||
availableCount: 1,
|
||||
nextExpiresAt: 1719326400 * 1000,
|
||||
credits: [
|
||||
{
|
||||
status: 'available',
|
||||
expiresAt: 1719326400 * 1000,
|
||||
grantedAt: 1718721600000
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(readFileMock).not.toHaveBeenCalled()
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs rate-limit RPC through WSL when the Codex home is a WSL managed account', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
if (msg.method === 'account/rateLimits/read') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { rateLimits: { primary: { usedPercent: 11 } } }
|
||||
})}\n`
|
||||
)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const resultPromise = fetchCodexRateLimits({
|
||||
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await resultPromise
|
||||
|
||||
const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0]
|
||||
expect(spawnFile).toBe('wsl.exe')
|
||||
expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'sh', '-c'])
|
||||
const shellCommand = spawnArgs.at(-1) as string
|
||||
expect(shellCommand).toContain('_orca_wsl_shell=$(getent passwd')
|
||||
expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "$_orca_wsl_shell" -ilc')
|
||||
expect(shellCommand).toContain(
|
||||
'exec 3<&0\nexec 4>&1\nexec </dev/null\nexec >/dev/null\n_orca_wsl_shell='
|
||||
)
|
||||
expect(shellCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
|
||||
expect(shellCommand).toContain('cd "$orca_rate_limit_cwd"')
|
||||
expect(shellCommand).toContain(
|
||||
"export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''"
|
||||
)
|
||||
expect(shellCommand).toContain(
|
||||
"exec codex '\\''-c'\\'' '\\''approval_policy=never'\\'' '\\''-c'\\'' '\\''features.plugins=false'\\'' '\\''-s'\\'' '\\''read-only'\\'' '\\''-a'\\'' '\\''never'\\'' '\\''app-server'\\'' <&3 >&4 3<&- 4>&-"
|
||||
)
|
||||
expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3)
|
||||
expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3)
|
||||
expect(shellCommand).not.toContain('_orca_codex')
|
||||
expect(shellCommand).not.toContain('wsl-codex-path')
|
||||
expect(spawnOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
cwd: 'C:\\Windows',
|
||||
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('routes Windows host Codex homes through the host RPC path', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
const codexCommand = 'C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd'
|
||||
resolveCodexCommandMock.mockReturnValue(codexCommand)
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
if (msg.method === 'account/rateLimits/read') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { rateLimits: { primary: { usedPercent: 13 } } }
|
||||
})}\n`
|
||||
)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const resultPromise = fetchCodexRateLimits({ codexHomePath: 'C:\\Users\\alice\\.codex' })
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await resultPromise
|
||||
|
||||
const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0]
|
||||
// Pre-wrapping in cmd.exe would hide the npm shim from spawnProcess's resolver.
|
||||
expect(spawnFile).toBe(codexCommand)
|
||||
expect(spawnArgs).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS])
|
||||
expect(spawnOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ CODEX_HOME: 'C:\\Users\\alice\\.codex' })
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('does not retry HTTP after RPC fails for a WSL home, since WSL already tried HTTP first', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } })
|
||||
)
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 }))
|
||||
|
||||
try {
|
||||
const resultPromise = fetchCodexRateLimits({
|
||||
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
rpcChild.emit('close')
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({ status: 'error' })
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,227 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const keychainState = vi.hoisted<{ token: string | null; error: Error | null }>(() => ({
|
||||
token: null,
|
||||
error: null
|
||||
}))
|
||||
const files = vi.hoisted<() => Map<string, string | Error>>(() => {
|
||||
const map = new Map<string, string | Error>()
|
||||
return () => map
|
||||
})
|
||||
const desktopState = vi.hoisted<{
|
||||
result: { status: string; profile?: unknown; error?: string }
|
||||
}>(() => ({ result: { status: 'missing' } }))
|
||||
|
||||
vi.mock('../macos-keychain/generic-password', () => ({
|
||||
readKeychainPassword: async () => {
|
||||
if (keychainState.error) {
|
||||
throw keychainState.error
|
||||
}
|
||||
return keychainState.token
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: (path: string) => files().has(path),
|
||||
readFileSync: (path: string) => {
|
||||
const entry = files().get(path)
|
||||
if (entry instanceof Error) {
|
||||
throw entry
|
||||
}
|
||||
if (entry === undefined) {
|
||||
throw new Error('ENOENT')
|
||||
}
|
||||
return entry
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../foreign-sqlite-readers/foreign-sqlite-reader-spawn', () => ({
|
||||
readCursorDesktopProfile: async () => desktopState.result
|
||||
}))
|
||||
|
||||
import { readCursorAuthSession, readCursorCliIdentity } from './cursor-auth'
|
||||
|
||||
type JwtSegment = Record<string, unknown>
|
||||
|
||||
function expiredJwt(sub: string): string {
|
||||
const encode = (value: JwtSegment): string =>
|
||||
Buffer.from(JSON.stringify(value)).toString('base64url')
|
||||
return `${encode({ alg: 'RS256' })}.${encode({ sub, exp: 1_000 })}.signature`
|
||||
}
|
||||
|
||||
function jwt(sub: string): string {
|
||||
const encode = (value: JwtSegment): string =>
|
||||
Buffer.from(JSON.stringify(value)).toString('base64url')
|
||||
return `${encode({ alg: 'RS256' })}.${encode({ sub, exp: 2_000_000_000 })}.signature`
|
||||
}
|
||||
|
||||
const CLI_AUTH = '/cli/auth.json'
|
||||
const CLI_CONFIG = '/cli/cli-config.json'
|
||||
const DESKTOP_DB = '/desktop/state.vscdb'
|
||||
const options = {
|
||||
cliAuthPath: CLI_AUTH,
|
||||
cliConfigPath: CLI_CONFIG,
|
||||
desktopStateDbPath: DESKTOP_DB
|
||||
}
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Keychain reads are macOS-only; pin the platform rather than inherit the runner.
|
||||
setPlatform('darwin')
|
||||
keychainState.token = null
|
||||
keychainState.error = null
|
||||
files().clear()
|
||||
desktopState.result = { status: 'missing' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('readCursorAuthSession', () => {
|
||||
it('prefers the keychain session cursor-agent 2026.06+ writes', async () => {
|
||||
keychainState.token = jwt('auth0|user_keychain')
|
||||
files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') }))
|
||||
const result = await readCursorAuthSession(options)
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.status === 'ok' && result.session.source).toBe('keychain')
|
||||
expect(result.status === 'ok' && result.session.token.subject).toBe('auth0|user_keychain')
|
||||
})
|
||||
|
||||
it('names the signed-in account from cli-config.json, which holds no token', async () => {
|
||||
keychainState.token = jwt('auth0|user_1')
|
||||
files().set(
|
||||
CLI_CONFIG,
|
||||
JSON.stringify({ authInfo: { email: 'dev@example.com', displayName: 'Dev' } })
|
||||
)
|
||||
const result = await readCursorAuthSession(options)
|
||||
expect(result.status === 'ok' && result.session.email).toBe('dev@example.com')
|
||||
expect(result.status === 'ok' && result.session.displayName).toBe('Dev')
|
||||
})
|
||||
|
||||
it('falls back to the legacy CLI auth file when the keychain holds nothing', async () => {
|
||||
files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') }))
|
||||
const result = await readCursorAuthSession(options)
|
||||
expect(result.status === 'ok' && result.session.source).toBe('cli')
|
||||
})
|
||||
|
||||
it('falls back to Cursor IDE when no CLI session exists', async () => {
|
||||
desktopState.result = {
|
||||
status: 'ok',
|
||||
profile: {
|
||||
accessToken: jwt('auth0|user_ide'),
|
||||
email: 'ide@example.com',
|
||||
membershipType: 'pro',
|
||||
subscriptionStatus: 'active'
|
||||
}
|
||||
}
|
||||
const result = await readCursorAuthSession(options)
|
||||
expect(result.status === 'ok' && result.session.source).toBe('desktop')
|
||||
expect(result.status === 'ok' && result.session.membershipType).toBe('pro')
|
||||
})
|
||||
|
||||
it('does not let a locked keychain hide a readable CLI session', async () => {
|
||||
keychainState.error = new Error('User interaction is not allowed')
|
||||
files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') }))
|
||||
const result = await readCursorAuthSession(options)
|
||||
expect(result.status === 'ok' && result.session.source).toBe('cli')
|
||||
})
|
||||
|
||||
it('reports the first read failure when no source yields a session', async () => {
|
||||
keychainState.error = new Error('User interaction is not allowed')
|
||||
const result = await readCursorAuthSession(options)
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
error: 'Unable to read the Cursor login from the macOS Keychain'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports signed out when nothing is stored anywhere', async () => {
|
||||
expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' })
|
||||
})
|
||||
|
||||
it('treats a signed-out CLI auth file as missing, not as an error', async () => {
|
||||
files().set(CLI_AUTH, JSON.stringify({}))
|
||||
expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' })
|
||||
})
|
||||
|
||||
it('keeps the local auth path out of the surfaced error', async () => {
|
||||
files().set(
|
||||
CLI_AUTH,
|
||||
new Error('EACCES: permission denied, open /Users/someone/.cursor/auth.json')
|
||||
)
|
||||
const result = await readCursorAuthSession(options)
|
||||
expect(result).toEqual({ status: 'error', error: 'Unable to read the Cursor CLI auth file' })
|
||||
})
|
||||
|
||||
it('reports invalid JSON distinctly from an unreadable file', async () => {
|
||||
files().set(CLI_AUTH, '{ not json')
|
||||
const result = await readCursorAuthSession(options)
|
||||
expect(result).toEqual({ status: 'error', error: 'Cursor CLI auth file is invalid' })
|
||||
})
|
||||
|
||||
it('prefers a live desktop session over an expired keychain one', async () => {
|
||||
// An expired CLI token must not mask a live IDE session.
|
||||
keychainState.token = expiredJwt('auth0|user_stale')
|
||||
desktopState.result = {
|
||||
status: 'ok',
|
||||
profile: {
|
||||
accessToken: jwt('auth0|user_ide'),
|
||||
email: 'ide@example.com',
|
||||
membershipType: 'pro',
|
||||
subscriptionStatus: 'active'
|
||||
}
|
||||
}
|
||||
const result = await readCursorAuthSession(options)
|
||||
expect(result.status === 'ok' && result.session.source).toBe('desktop')
|
||||
expect(result.status === 'ok' && result.session.token.subject).toBe('auth0|user_ide')
|
||||
})
|
||||
|
||||
it('still returns the expired session when no live one exists anywhere', async () => {
|
||||
keychainState.token = expiredJwt('auth0|user_stale')
|
||||
const result = await readCursorAuthSession(options)
|
||||
expect(result.status === 'ok' && result.session.source).toBe('keychain')
|
||||
})
|
||||
|
||||
it('never reads the keychain off macOS and falls through to the CLI file', async () => {
|
||||
setPlatform('linux')
|
||||
keychainState.token = jwt('auth0|user_keychain')
|
||||
files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') }))
|
||||
const result = await readCursorAuthSession(options)
|
||||
expect(result.status === 'ok' && result.session.source).toBe('cli')
|
||||
})
|
||||
|
||||
it('reports signed out off macOS when only a keychain session exists', async () => {
|
||||
setPlatform('win32')
|
||||
keychainState.token = jwt('auth0|user_keychain')
|
||||
expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' })
|
||||
})
|
||||
|
||||
it('skips a stored token that carries no subject', async () => {
|
||||
keychainState.token = 'not-a-jwt'
|
||||
expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readCursorCliIdentity', () => {
|
||||
it('returns empty identity for a config with no authInfo', () => {
|
||||
files().set(CLI_CONFIG, JSON.stringify({ version: 1 }))
|
||||
expect(readCursorCliIdentity(CLI_CONFIG)).toEqual({
|
||||
email: null,
|
||||
displayName: null,
|
||||
membershipType: null,
|
||||
subscriptionStatus: null
|
||||
})
|
||||
})
|
||||
|
||||
it('survives a corrupt config file', () => {
|
||||
files().set(CLI_CONFIG, '{{{')
|
||||
expect(readCursorCliIdentity(CLI_CONFIG).email).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,282 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setMainHttpClient } from '../network/http-client'
|
||||
import { fetchCursorRateLimits } from './cursor-fetcher'
|
||||
import type { CursorAuthReadResult } from './cursor-auth'
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
const SUMMARY_URL = 'https://cursor.com/api/usage-summary'
|
||||
const CURRENT_PERIOD_URL = 'https://cursor.com/api/dashboard/get-current-period-usage'
|
||||
const START = '2026-10-18T06:32:13.504Z'
|
||||
const END = '2026-10-25T06:32:13.504Z'
|
||||
const PERCENT = 1.112013719649788
|
||||
const CURRENT = {
|
||||
billingCycleStart: START,
|
||||
billingCycleEnd: END,
|
||||
planUsage: { totalPercentUsed: PERCENT, autoPercentUsed: PERCENT, apiPercentUsed: 0 }
|
||||
}
|
||||
const SUMMARY = {
|
||||
membershipType: 'unified_ultra',
|
||||
billingCycleStart: '2026-10-01T00:00:00.000Z',
|
||||
billingCycleEnd: '2026-11-01T00:00:00.000Z',
|
||||
individualUsage: {
|
||||
plan: {
|
||||
enabled: true,
|
||||
used: 0,
|
||||
limit: 55395,
|
||||
totalPercentUsed: 0,
|
||||
autoPercentUsed: 0,
|
||||
apiPercentUsed: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
const AUTH: CursorAuthReadResult = {
|
||||
status: 'ok',
|
||||
session: {
|
||||
token: { raw: 'test-cli-token', subject: 'test-cli-account', expiresAtMs: 4_000_000_000_000 },
|
||||
source: 'keychain',
|
||||
email: null,
|
||||
displayName: null,
|
||||
membershipType: null,
|
||||
subscriptionStatus: null
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
setMainHttpClient({ fetch: fetchMock, proxySession: () => null })
|
||||
})
|
||||
|
||||
afterEach(() => setMainHttpClient(null))
|
||||
|
||||
function respond(current: unknown = CURRENT, summary: unknown = SUMMARY): void {
|
||||
fetchMock.mockImplementation(async (url) => {
|
||||
if (url === SUMMARY_URL) {
|
||||
return Response.json(summary)
|
||||
}
|
||||
if (url === CURRENT_PERIOD_URL) {
|
||||
return Response.json(current)
|
||||
}
|
||||
throw new Error('Unexpected request')
|
||||
})
|
||||
}
|
||||
|
||||
describe('Cursor Unified Ultra current-period usage', () => {
|
||||
it('replaces stale zero monthly figures with both current weekly pools using the same credential', async () => {
|
||||
respond()
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
|
||||
expect(limits).toMatchObject({ status: 'ok', planType: 'unified_ultra' })
|
||||
expect(limits.buckets).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'Cursor Models',
|
||||
usedPercent: PERCENT,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: Date.parse(END)
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'Other Models',
|
||||
usedPercent: 0,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: Date.parse(END)
|
||||
})
|
||||
])
|
||||
expect(limits.monthly).toMatchObject({
|
||||
usedPercent: PERCENT,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: Date.parse(END)
|
||||
})
|
||||
expect(limits.usageMetadata).toMatchObject({ source: 'cli', credentialSource: 'keychain' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
const summaryRequest = fetchMock.mock.calls.find(([url]) => url === SUMMARY_URL)?.[1]
|
||||
const currentRequest = fetchMock.mock.calls.find(([url]) => url === CURRENT_PERIOD_URL)?.[1]
|
||||
expect(currentRequest).toMatchObject({
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
redirect: 'manual',
|
||||
credentials: 'omit',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: 'WorkosCursorSessionToken=test-cli-account%3A%3Atest-cli-token',
|
||||
Origin: 'https://cursor.com',
|
||||
Referer: 'https://cursor.com/dashboard'
|
||||
}
|
||||
})
|
||||
expect(new Headers(summaryRequest?.headers).get('Cookie')).toBe(
|
||||
new Headers(currentRequest?.headers).get('Cookie')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a genuine zero current period authoritative and preserves independently billed on-demand usage', async () => {
|
||||
respond(
|
||||
{ ...CURRENT, planUsage: { totalPercentUsed: '0', autoPercentUsed: '0', apiPercentUsed: 0 } },
|
||||
{
|
||||
...SUMMARY,
|
||||
individualUsage: {
|
||||
plan: { ...SUMMARY.individualUsage.plan, totalPercentUsed: 80, autoPercentUsed: 80 },
|
||||
onDemand: { enabled: true, used: 25, limit: 100 }
|
||||
}
|
||||
}
|
||||
)
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
|
||||
expect(limits.monthly?.usedPercent).toBe(0)
|
||||
expect(limits.buckets).toEqual([
|
||||
expect.objectContaining({ name: 'Cursor Models', usedPercent: 0, resetsAt: Date.parse(END) }),
|
||||
expect.objectContaining({ name: 'Other Models', usedPercent: 0, resetsAt: Date.parse(END) }),
|
||||
expect.objectContaining({
|
||||
name: 'On-demand',
|
||||
usedPercent: 25,
|
||||
resetsAt: Date.parse(SUMMARY.billingCycleEnd)
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
{ ...CURRENT, planUsage: null },
|
||||
{ ...CURRENT, planUsage: { totalPercentUsed: 'invalid', autoPercentUsed: null } },
|
||||
{ ...CURRENT, billingCycleStart: 'invalid' },
|
||||
{ ...CURRENT, billingCycleEnd: START }
|
||||
])(
|
||||
'retains summary compatibility when the current-period shape is unusable: %j',
|
||||
async (current) => {
|
||||
respond(current)
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
|
||||
expect(limits.status).toBe('ok')
|
||||
expect(limits.monthly).toMatchObject({
|
||||
usedPercent: 0,
|
||||
resetsAt: Date.parse(SUMMARY.billingCycleEnd)
|
||||
})
|
||||
expect(fetchMock.mock.calls.some(([url]) => url === CURRENT_PERIOD_URL)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['HTTP 404', () => new Response(null, { status: 404 })],
|
||||
['HTTP 405', () => new Response(null, { status: 405 })],
|
||||
['HTTP 501', () => new Response(null, { status: 501 })],
|
||||
// Cursor answers unknown dashboard routes with its HTML app shell.
|
||||
[
|
||||
'200 HTML',
|
||||
() => new Response('<!DOCTYPE html>', { headers: { 'content-type': 'text/html' } })
|
||||
]
|
||||
])(
|
||||
'falls back to legacy request quota when current-period is unsupported (%s)',
|
||||
async (_label, currentResponse) => {
|
||||
fetchMock.mockImplementation(async (url) => {
|
||||
if (url === SUMMARY_URL) {
|
||||
return Response.json({ membershipType: 'unified_ultra' })
|
||||
}
|
||||
if (url === CURRENT_PERIOD_URL) {
|
||||
return currentResponse()
|
||||
}
|
||||
return Response.json({ 'gpt-4': { numRequests: 25, maxRequestUsage: 50 } })
|
||||
})
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
|
||||
expect(limits.monthly?.usedPercent).toBe(50)
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
SUMMARY_URL,
|
||||
CURRENT_PERIOD_URL,
|
||||
'https://cursor.com/api/usage?user=test-cli-account'
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it.each([401, 403, 429, 503])(
|
||||
'preserves current-period HTTP %i instead of publishing obsolete healthy zeros',
|
||||
async (status) => {
|
||||
fetchMock.mockImplementation(async (url) =>
|
||||
url === SUMMARY_URL
|
||||
? Response.json(SUMMARY)
|
||||
: new Response(null, { status, headers: { 'retry-after': '60' } })
|
||||
)
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
|
||||
expect(limits.status).toBe('error')
|
||||
expect(limits.monthly).toBeUndefined()
|
||||
expect(limits.usageMetadata?.authProvenance).toBeTruthy()
|
||||
expect(limits.usageMetadata?.failureKind).toBe(
|
||||
status === 401 ? 'stale-token' : status === 429 ? 'rate-limited' : 'server'
|
||||
)
|
||||
if (status === 429) {
|
||||
expect(limits.usageMetadata?.retryAtMs).toBeGreaterThan(Date.now())
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not publish obsolete zeros after a failed current-period request', async () => {
|
||||
fetchMock.mockImplementation(async (url) => {
|
||||
if (url === SUMMARY_URL) {
|
||||
return Response.json(SUMMARY)
|
||||
}
|
||||
throw new Error('test network failure')
|
||||
})
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
|
||||
expect(limits.status).toBe('error')
|
||||
expect(limits.usageMetadata?.failureKind).toBe('network')
|
||||
expect(limits.monthly).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps an active non-unified plan on the summary without the extra request', async () => {
|
||||
const plan = { ...SUMMARY.individualUsage.plan, totalPercentUsed: 12, autoPercentUsed: 12 }
|
||||
respond(CURRENT, { ...SUMMARY, membershipType: 'pro', individualUsage: { plan } })
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
|
||||
expect(limits.planType).toBe('pro')
|
||||
expect(limits.monthly?.resetsAt).toBe(Date.parse(SUMMARY.billingCycleEnd))
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps an unlimited non-unified plan barless without the extra request', async () => {
|
||||
respond(CURRENT, { ...SUMMARY, membershipType: 'enterprise', isUnlimited: true })
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
|
||||
expect(limits).toMatchObject({ status: 'ok', planType: 'enterprise' })
|
||||
expect(limits.monthly).toBeUndefined()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Weekly plans are not all unified_*: e.g. an enterprise seat moved to weekly Premium.
|
||||
it('uses a weekly current period behind idle non-unified summary pools', async () => {
|
||||
const endMs = Date.parse(END)
|
||||
const weekly = {
|
||||
...CURRENT,
|
||||
billingCycleStart: String(Date.parse(START)),
|
||||
billingCycleEnd: String(endMs)
|
||||
}
|
||||
respond(weekly, { ...SUMMARY, membershipType: 'enterprise' })
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
|
||||
expect(limits).toMatchObject({ status: 'ok', planType: 'enterprise' })
|
||||
expect(limits.monthly).toMatchObject({
|
||||
usedPercent: PERCENT,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: endMs
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a monthly period',
|
||||
() =>
|
||||
Response.json({
|
||||
...CURRENT,
|
||||
billingCycleStart: SUMMARY.billingCycleStart,
|
||||
billingCycleEnd: SUMMARY.billingCycleEnd,
|
||||
planUsage: { totalPercentUsed: 9 }
|
||||
})
|
||||
],
|
||||
['HTTP 429', () => new Response(null, { status: 429 })],
|
||||
['a network failure', () => Promise.reject(new Error('test network failure'))]
|
||||
])(
|
||||
'keeps idle non-unified summary pools when current-period returns %s',
|
||||
async (_label, current) => {
|
||||
fetchMock.mockImplementation(async (url) =>
|
||||
url === SUMMARY_URL ? Response.json({ ...SUMMARY, membershipType: 'pro' }) : current()
|
||||
)
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
|
||||
expect(limits.status).toBe('ok')
|
||||
expect(limits.monthly).toMatchObject({
|
||||
usedPercent: 0,
|
||||
resetsAt: Date.parse(SUMMARY.billingCycleEnd)
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,241 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setMainHttpClient } from '../network/http-client'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
import { fetchCursorRateLimits } from './cursor-fetcher'
|
||||
import type { CursorAuthReadResult } from './cursor-auth'
|
||||
import { parseCursorSessionToken } from './cursor-session-token'
|
||||
|
||||
setMainHttpClient({ fetch: fetchMock, proxySession: () => null })
|
||||
|
||||
type JwtSegment = Record<string, unknown>
|
||||
|
||||
function jwt(exp: number): string {
|
||||
const encode = (value: JwtSegment): string =>
|
||||
Buffer.from(JSON.stringify(value)).toString('base64url')
|
||||
return `${encode({ alg: 'RS256' })}.${encode({ sub: 'auth0|user_1', exp })}.signature`
|
||||
}
|
||||
|
||||
function session(expSeconds = 4_000_000_000): CursorAuthReadResult {
|
||||
return {
|
||||
status: 'ok',
|
||||
session: {
|
||||
token: parseCursorSessionToken(jwt(expSeconds))!,
|
||||
source: 'keychain',
|
||||
email: 'dev@example.com',
|
||||
displayName: 'Dev',
|
||||
membershipType: null,
|
||||
subscriptionStatus: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FakeResponse = Pick<Response, 'ok' | 'status'> & {
|
||||
headers: Pick<Headers, 'get'>
|
||||
json: () => Promise<unknown>
|
||||
}
|
||||
|
||||
function fakeResponse(response: FakeResponse): Response {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fetcher only reads ok/status/headers.get/json, all of which FakeResponse provides.
|
||||
return response as unknown as Response
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return fakeResponse({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name: string) => headers[name.toLowerCase()] ?? null },
|
||||
json: async () => body
|
||||
})
|
||||
}
|
||||
|
||||
const SUMMARY = {
|
||||
billingCycleStart: '2026-09-01T00:00:00.000Z',
|
||||
billingCycleEnd: '2026-10-01T00:00:00.000Z',
|
||||
membershipType: 'pro',
|
||||
individualUsage: {
|
||||
plan: { enabled: true, used: 2_500, limit: 5_000, autoPercentUsed: 40, apiPercentUsed: 12 }
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
})
|
||||
|
||||
describe('fetchCursorRateLimits', () => {
|
||||
it('publishes both plan pools and the cycle reset', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(SUMMARY))
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(limits.status).toBe('ok')
|
||||
expect(limits.monthly?.usedPercent).toBe(50)
|
||||
expect(limits.buckets?.map((bucket) => bucket.name)).toEqual(['Cursor Models', 'Other Models'])
|
||||
expect(limits.planType).toBe('pro')
|
||||
expect(limits.usageMetadata).toMatchObject({ source: 'cli', credentialSource: 'keychain' })
|
||||
})
|
||||
|
||||
it('sends the origin headers the dashboard checks as CSRF defence', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(SUMMARY))
|
||||
await fetchCursorRateLimits({ authReadResult: session() })
|
||||
const [url, init] = fetchMock.mock.calls[0] ?? []
|
||||
expect(url).toBe('https://cursor.com/api/usage-summary')
|
||||
expect(init?.credentials).toBe('omit')
|
||||
expect(init?.headers).toMatchObject({
|
||||
Origin: 'https://cursor.com',
|
||||
Referer: 'https://cursor.com/dashboard',
|
||||
Cookie: expect.stringContaining('WorkosCursorSessionToken=auth0%7Cuser_1%3A%3A')
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an expired session without spending a request that must 401', async () => {
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: session(1_000) })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(limits.status).toBe('error')
|
||||
expect(limits.usageMetadata?.failureKind).toBe('stale-token')
|
||||
expect(limits.error).toContain('cursor-agent login')
|
||||
})
|
||||
|
||||
it('tells a signed-out user how to sign in, without painting an error bar', async () => {
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: { status: 'missing' } })
|
||||
expect(limits.status).toBe('unavailable')
|
||||
expect(limits.usageMetadata?.failureKind).toBe('missing-credentials')
|
||||
})
|
||||
|
||||
it('surfaces a credential read failure', async () => {
|
||||
const limits = await fetchCursorRateLimits({
|
||||
authReadResult: { status: 'error', error: 'Unable to read the Cursor CLI auth file' }
|
||||
})
|
||||
expect(limits.status).toBe('error')
|
||||
expect(limits.error).toBe('Unable to read the Cursor CLI auth file')
|
||||
})
|
||||
|
||||
it('treats a rejected session as expired sign-in and records a capped Retry-After on 429', async () => {
|
||||
vi.useFakeTimers({ now: Date.parse('2026-10-13T12:00:00Z') })
|
||||
try {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, 401))
|
||||
expect(
|
||||
(await fetchCursorRateLimits({ authReadResult: session() })).usageMetadata?.failureKind
|
||||
).toBe('stale-token')
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': '60' }))
|
||||
const limited = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(limited.usageMetadata?.failureKind).toBe('rate-limited')
|
||||
expect(limited.usageMetadata?.retryAtMs).toBe(Date.now() + 60_000)
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': '99999999' }))
|
||||
const capped = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(capped.usageMetadata?.retryAtMs).toBe(Date.now() + 24 * 60 * 60 * 1000)
|
||||
|
||||
const retryAt = Date.now() + 5 * 60_000
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({}, 429, { 'retry-after': new Date(retryAt).toUTCString() })
|
||||
)
|
||||
const dated = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(dated.usageMetadata?.retryAtMs).toBe(retryAt)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([403, 302, 304])('reports HTTP %i without claiming the login expired', async (status) => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, status, { location: '/login' }))
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(limits.usageMetadata?.failureKind).toBe('server')
|
||||
expect(limits.error).toBe(`Cursor usage request failed (HTTP ${status})`)
|
||||
expect(limits.usageMetadata?.authProvenance).toBeTruthy()
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.redirect).toBe('manual')
|
||||
})
|
||||
|
||||
it('preserves a forbidden response from the legacy usage endpoint', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ membershipType: 'free' }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, 403))
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(limits.error).toBe('Cursor usage request failed (HTTP 403)')
|
||||
expect(limits.usageMetadata?.failureKind).toBe('server')
|
||||
})
|
||||
|
||||
it('names the account on failures too, so an account switch can clear stale figures', async () => {
|
||||
// Failed refreshes must still name the new account so stale-policy code can discard the previous account's quota.
|
||||
const ok = jsonResponse(SUMMARY)
|
||||
fetchMock.mockResolvedValueOnce(ok)
|
||||
const success = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
const fingerprint = success.usageMetadata?.authProvenance
|
||||
expect(fingerprint).toBeTruthy()
|
||||
|
||||
for (const [status, headers] of [
|
||||
[401, {}],
|
||||
[429, { 'retry-after': '60' }],
|
||||
[503, {}]
|
||||
] as const) {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, status, headers))
|
||||
const failure = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(failure.status).toBe('error')
|
||||
expect(failure.usageMetadata?.authProvenance).toBe(fingerprint)
|
||||
}
|
||||
|
||||
const expired = await fetchCursorRateLimits({ authReadResult: session(1_000) })
|
||||
expect(expired.usageMetadata?.authProvenance).toBe(fingerprint)
|
||||
})
|
||||
|
||||
it('leaves the account unnamed when no session could be read', async () => {
|
||||
const missing = await fetchCursorRateLimits({ authReadResult: { status: 'missing' } })
|
||||
expect(missing.usageMetadata?.authProvenance).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a server failure with its status code', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, 503))
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(limits.error).toBe('Cursor usage request failed (HTTP 503)')
|
||||
expect(limits.usageMetadata?.failureKind).toBe('server')
|
||||
})
|
||||
|
||||
it('classifies an unparseable body as a parse failure, not as usage', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
fakeResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: async () => {
|
||||
throw new Error('invalid json')
|
||||
}
|
||||
})
|
||||
)
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(limits.usageMetadata?.failureKind).toBe('parse')
|
||||
})
|
||||
|
||||
it('publishes an unlimited plan with no misleading bar', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ isUnlimited: true, membershipType: 'ultra' }))
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(limits.status).toBe('ok')
|
||||
expect(limits.monthly).toBeUndefined()
|
||||
expect(limits.planType).toBe('ultra')
|
||||
})
|
||||
|
||||
it('falls back to the request-quota endpoint before declaring no allowance', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ membershipType: 'free' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ 'gpt-4': { numRequests: 25, maxRequestUsage: 50 } }))
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(limits.status).toBe('ok')
|
||||
expect(limits.monthly?.usedPercent).toBe(50)
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://cursor.com/api/usage?user=auth0%7Cuser_1')
|
||||
})
|
||||
|
||||
it('hides the bar for an account with no quota rather than alerting forever', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ membershipType: 'free' }))
|
||||
.mockResolvedValueOnce(jsonResponse({}))
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(limits.status).toBe('unavailable')
|
||||
expect(limits.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
})
|
||||
|
||||
it('reports a network failure without leaking the request internals', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND cursor.com'))
|
||||
const limits = await fetchCursorRateLimits({ authReadResult: session() })
|
||||
expect(limits.error).toBe('Cursor usage request failed')
|
||||
expect(limits.usageMetadata?.failureKind).toBe('network')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user