mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
test: remove 208 low-value test declarations (#37201)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -509,7 +509,6 @@
|
||||
3 test-no-real-time-wait:src/renderer/src/lib/execute-open-editor-path-move.test.ts
|
||||
1 test-no-real-time-wait:src/renderer/src/lib/native-chat-cli-fork-preparation.test.ts
|
||||
1 test-no-real-time-wait:src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts
|
||||
1 test-no-real-time-wait:src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts
|
||||
3 test-no-real-time-wait:src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts
|
||||
1 test-no-real-time-wait:src/renderer/src/runtime/runtime-host-account-sign-in-client.test.ts
|
||||
1 test-no-real-time-wait:src/renderer/src/store/slices/github-pr-checks-fetch.test.ts
|
||||
@@ -1132,7 +1131,7 @@
|
||||
1 test-no-wall-clock:src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts
|
||||
2 test-no-wall-clock:src/renderer/src/store/slices/workspace-cleanup-agent-stop-preflight.test.ts
|
||||
4 test-no-wall-clock:src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts
|
||||
3 test-no-wall-clock:src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts
|
||||
2 test-no-wall-clock:src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/store/slices/worktrees-always-force-delete.test.ts
|
||||
6 test-no-wall-clock:src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/store/slices/worktrees-terminal-reference-origins.test.ts
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { spawnProcess } from '@orca/process-host'
|
||||
import type { SpawnedProcess, ProcessSpec } from '@orca/process-host/process-spec'
|
||||
|
||||
import { query, type CanUseTool, type Options } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { query, CanUseTool } from '@anthropic-ai/claude-agent-sdk'
|
||||
import {
|
||||
openClaudeStreamJsonConnection,
|
||||
type ClaudeStreamJsonConnection,
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from './claude-stream-json-connection'
|
||||
import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory'
|
||||
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { claudeAuthDiagnostic } from './structured/claude-structured-init-proof'
|
||||
import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation'
|
||||
import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './structured/launch/claude-structured-launch-resolution'
|
||||
import {
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
|
||||
import { claudeProviderHandle } from '../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
|
||||
import { providerExecutableMissing } from '../provider-process/provider-executable-missing'
|
||||
import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text'
|
||||
|
||||
// Exercise the real SDK against a scripted child CLI.
|
||||
const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs')
|
||||
@@ -158,17 +156,6 @@ function processState(pid: number): 'running' | 'exited' {
|
||||
}
|
||||
|
||||
describe('Claude stream-json connection', () => {
|
||||
it('passes the Claude Code system-prompt preset through to SDK query', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN])
|
||||
let captured: Options | undefined
|
||||
await open(launchFor(scenario), {}, (params) => {
|
||||
captured = params.options
|
||||
return query(params)
|
||||
})
|
||||
|
||||
expect(captured?.systemPrompt).toEqual({ type: 'preset', preset: 'claude_code' })
|
||||
})
|
||||
|
||||
it('hands the child a derived environment, the resolved CLI path, and keeps the pid', async () => {
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-SHELL-LEAK')
|
||||
vi.stubEnv('CLAUDE_CODE_CHILD_SESSION', '1')
|
||||
@@ -258,14 +245,6 @@ describe('Claude stream-json connection', () => {
|
||||
expect(env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666')
|
||||
})
|
||||
|
||||
it('leaves the default CLI home unpinned so macOS Keychain OAuth keeps working', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN])
|
||||
await open(launchFor(scenario))
|
||||
|
||||
await until(() => readReportSafely(scenario), 'the scripted CLI report')
|
||||
expect(childEnv().CLAUDE_CONFIG_DIR).toBeUndefined()
|
||||
})
|
||||
|
||||
it("drops a CLAUDE_CONFIG_DIR from Orca's own process env: only the launch may name a home", async () => {
|
||||
vi.stubEnv('CLAUDE_CONFIG_DIR', '/orca/process/claude')
|
||||
vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited')
|
||||
@@ -330,21 +309,6 @@ describe('Claude stream-json connection', () => {
|
||||
expect(readReportSafely(scenario)?.userMessages ?? []).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('delivers an unmodeled frame verbatim so the provider-fallback row survives', async () => {
|
||||
const unknown = {
|
||||
type: 'frame_kind_from_the_future',
|
||||
session_id: SESSION_ID,
|
||||
uuid: 'uuid-unknown-1',
|
||||
payload: { nested: { flags: ['a', 'b'] } }
|
||||
}
|
||||
const scenario = scriptScenario([{ emit: unknown }, HOLD_OPEN])
|
||||
const messages: Record<string, unknown>[] = []
|
||||
await open(launchFor(scenario), { onMessage: (message) => messages.push(message) })
|
||||
|
||||
await until(() => messages.find((message) => message.uuid === 'uuid-unknown-1'), 'the frame')
|
||||
expect(messages.find((message) => message.uuid === 'uuid-unknown-1')).toEqual(unknown)
|
||||
})
|
||||
|
||||
it('commits the real partial-message cadence as one assistant item through the translator', async () => {
|
||||
// Claude Code 2.1.258 partial-message order: distinct frame UUIDs correlate through message.id.
|
||||
const stream = (uuid: string, event: Record<string, unknown>) => ({
|
||||
@@ -501,82 +465,6 @@ describe('Claude stream-json connection', () => {
|
||||
expect(written.response.response).toMatchObject({ behavior: 'deny', message: 'No' })
|
||||
})
|
||||
|
||||
it('drives Orca control methods onto the SDK and times out with the init proof message', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN], {
|
||||
initialize: { models: [{ value: 'sonnet' }], account: { tokenSource: 'oauth' } },
|
||||
get_settings: { env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' } }
|
||||
})
|
||||
const connection = await open(launchFor(scenario))
|
||||
|
||||
await expect(connection.initializationResult()).resolves.toMatchObject({
|
||||
models: [{ value: 'sonnet' }]
|
||||
})
|
||||
await expect(connection.getSettings()).resolves.toEqual({
|
||||
env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' }
|
||||
})
|
||||
await expect(connection.setModel('opus')).resolves.toBeUndefined()
|
||||
const requests = await until(
|
||||
() =>
|
||||
readReportSafely(scenario)?.controlRequests.find(
|
||||
(frame) => frame.request.subtype === 'set_model'
|
||||
),
|
||||
'the set_model control request'
|
||||
)
|
||||
expect(requests.request.subtype).toBe('set_model')
|
||||
})
|
||||
|
||||
it('reads supportedModels from the catalog the running CLI reported', async () => {
|
||||
const scenario = scriptScenario([HOLD_OPEN], {
|
||||
initialize: {
|
||||
models: [
|
||||
{ value: 'default', resolvedModel: 'claude-opus-5' },
|
||||
{
|
||||
value: 'opus',
|
||||
displayName: 'Opus 5',
|
||||
description: 'The live row, not the seed',
|
||||
resolvedModel: 'claude-opus-5',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'high']
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const connection = await open(launchFor(scenario))
|
||||
|
||||
await expect(connection.supportedModels()).resolves.toMatchObject([
|
||||
{ value: 'default', resolvedModel: 'claude-opus-5' },
|
||||
{ value: 'opus', displayName: 'Opus 5', supportedEffortLevels: ['low', 'high'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('feeds the auth diagnostic from the settings the running child reports', async () => {
|
||||
for (const key of ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']) {
|
||||
vi.stubEnv(key, undefined)
|
||||
}
|
||||
const scenario = scriptScenario([HOLD_OPEN], {
|
||||
get_settings: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://settings.example.test',
|
||||
ANTHROPIC_AUTH_TOKEN: 'secret'
|
||||
}
|
||||
}
|
||||
})
|
||||
const connection = await open(launchFor(scenario))
|
||||
const init = { providerSessionId: SESSION_ID, uuid: null, model: null, message: {} }
|
||||
|
||||
expect(claudeAuthDiagnostic(null, init, null)).toMatchObject({
|
||||
baseUrlConfigured: false,
|
||||
authTokenConfigured: false
|
||||
})
|
||||
const diagnostic = claudeAuthDiagnostic(null, init, await connection.getSettings())
|
||||
expect(diagnostic).toMatchObject({
|
||||
baseUrlConfigured: true,
|
||||
authTokenConfigured: true,
|
||||
apiKeyConfigured: false
|
||||
})
|
||||
expect(JSON.stringify(diagnostic)).not.toContain('secret')
|
||||
})
|
||||
|
||||
it('settles an unanswered initialize when the child exits, with no timer of its own', async () => {
|
||||
const scenario = scriptScenario([{ stderr: 'claude: not signed in\n' }, { exit: 1 }])
|
||||
const connection = await open({
|
||||
@@ -614,24 +502,6 @@ describe('Claude stream-json connection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("names Claude's root refusal of Full access, keeping its words as the detail", async () => {
|
||||
const refusal =
|
||||
'--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons'
|
||||
const scenario = scriptScenario([{ stderr: `${refusal}\n` }, { exit: 1 }])
|
||||
let exit: Error | null = null
|
||||
await open(launchFor(scenario), {
|
||||
onExit: (error) => {
|
||||
exit = error
|
||||
}
|
||||
})
|
||||
|
||||
await until(() => exit, 'the exit error')
|
||||
expect(providerStartupFailureFact(exit)).toEqual({
|
||||
kind: 'fullAccessAsRoot',
|
||||
detail: { text: expect.stringContaining(refusal), audience: 'log' }
|
||||
})
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'proves a natural SDK exit and cleans up its descendant before recovery',
|
||||
async () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
|
||||
import { join } from 'node:path'
|
||||
import type * as CodexConfigMirror from '../codex/codex-config-mirror'
|
||||
import type * as CodexHomePaths from '../codex/codex-home-paths'
|
||||
import type * as CodexPaneAccountRegistry from '../codex/codex-pane-account-registry'
|
||||
import type * as LegacyWslRuntimeAuthDrain from './legacy-wsl-runtime-auth-drain'
|
||||
import type * as WslCodexAuthBatchReader from './wsl-codex-auth-batch-reader'
|
||||
import { createSettings } from './runtime-home-settings-test-fixtures'
|
||||
@@ -400,45 +399,6 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves legacy auth while draining when pane attribution is unavailable', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => wslHome,
|
||||
getWslHomeAsync: async () => wslHome
|
||||
}))
|
||||
vi.doMock('../codex/codex-pane-account-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexPaneAccountRegistry>()),
|
||||
hasRecordedLegacyWslCodexPane: () => {
|
||||
throw new Error('registry unavailable')
|
||||
}
|
||||
}))
|
||||
const startLegacyWslRuntimeAuthDrain = vi.fn(() => Promise.resolve())
|
||||
vi.doMock('./legacy-wsl-runtime-auth-drain', () => ({ startLegacyWslRuntimeAuthDrain }))
|
||||
const store = createStore(createSettings())
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
await service.prepareForCodexLaunchAsync({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(startLegacyWslRuntimeAuthDrain).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ legacyPanePresent: true }),
|
||||
{ throwOnFailure: true }
|
||||
)
|
||||
} finally {
|
||||
vi.doUnmock('./legacy-wsl-runtime-auth-drain')
|
||||
vi.doUnmock('../codex/codex-pane-account-registry')
|
||||
vi.doUnmock('../wsl')
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores stale retired runtime auth when launching a managed WSL account', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
@@ -52,11 +52,9 @@ import {
|
||||
isCodexConfigTomlRefusedError,
|
||||
moveHookTrustEntries,
|
||||
readHookTrustEntries,
|
||||
removeHookTrustEntries,
|
||||
upsertHookTrustEntries,
|
||||
type CodexTrustEntry
|
||||
} from './config-toml-trust'
|
||||
import { applyMirroredRuntimeUserHookTrustStates } from './codex-hook-user-mirroring'
|
||||
import { installCodexHooksRemote } from './codex-hook-remote-install'
|
||||
|
||||
let dir: string
|
||||
@@ -116,14 +114,6 @@ describe('hook approval writes never break a config.toml Codex can load', () =>
|
||||
expect(readFileSync(tomlPath, 'utf-8')).toBe(original)
|
||||
})
|
||||
|
||||
it('upsert may still repair a file Codex already cannot load', () => {
|
||||
writeFileSync(tomlPath, 'model = \n')
|
||||
|
||||
upsertHookTrustEntries(tomlPath, [stopEntry()])
|
||||
|
||||
expect(readFileSync(tomlPath, 'utf-8')).toContain('trusted_hash')
|
||||
})
|
||||
|
||||
it('a move refuses to land on a key the user approved as dotted keys', () => {
|
||||
const original =
|
||||
`hooks.state."${tomlKey(stopEntry(0))}".trusted_hash = "sha256:user"\n\n` +
|
||||
@@ -139,28 +129,6 @@ describe('hook approval writes never break a config.toml Codex can load', () =>
|
||||
expect(readFileSync(tomlPath, 'utf-8')).toBe(original)
|
||||
})
|
||||
|
||||
it('a removal is checked before it is written', () => {
|
||||
upsertHookTrustEntries(tomlPath, [stopEntry(0), { ...stopEntry(1), command: 'user.sh' }])
|
||||
const original = readFileSync(tomlPath, 'utf-8')
|
||||
mocks.rejectParse = (content) => !content.includes(computeTrustKey(stopEntry(0)))
|
||||
|
||||
expectRefusal(() => removeHookTrustEntries(tomlPath, [computeTrustKey(stopEntry(0))]))
|
||||
|
||||
expect(readFileSync(tomlPath, 'utf-8')).toBe(original)
|
||||
})
|
||||
|
||||
it("mirroring a user hook's enabled state is checked before it is written", () => {
|
||||
upsertHookTrustEntries(tomlPath, [{ ...stopEntry(), enabled: true }])
|
||||
const original = readFileSync(tomlPath, 'utf-8')
|
||||
mocks.rejectParse = (content) => content.includes('enabled = false')
|
||||
|
||||
expectRefusal(() =>
|
||||
applyMirroredRuntimeUserHookTrustStates(tomlPath, [{ entry: stopEntry(), enabled: false }])
|
||||
)
|
||||
|
||||
expect(readFileSync(tomlPath, 'utf-8')).toBe(original)
|
||||
})
|
||||
|
||||
it('the SSH installer reports the refusal and leaves the remote config.toml untouched', async () => {
|
||||
const remoteToml = '/home/u/.codex/config.toml'
|
||||
const original = 'model = "m"\nhooks = { state = {} }\n'
|
||||
|
||||
@@ -1,508 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted(
|
||||
() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileSyncMock: vi.fn(),
|
||||
translateWslOutputPathsMock: vi.fn((output: string) => output)
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('./runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
gitExecFileSync: gitExecFileSyncMock,
|
||||
translateWslOutputPaths: translateWslOutputPathsMock
|
||||
}))
|
||||
|
||||
// Keep the ordered runner queues; worktree-checkout-progress-real-git covers the checkout.workers read.
|
||||
vi.mock('./checkout-workers-git-args', () => ({ checkoutWorkersGitArgs: async () => [] }))
|
||||
|
||||
import { addSparseWorktree, addWorktree, WORKTREE_ADD_TIMEOUT_MS } from './worktree'
|
||||
import { registerWorktreeSuiteHooks } from './worktree-test-harness'
|
||||
|
||||
registerWorktreeSuiteHooks()
|
||||
|
||||
describe('addWorktree', () => {
|
||||
const resolveRemoteBase = () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' })
|
||||
}
|
||||
|
||||
const resolveCreationBaseConfigWrite = () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
}
|
||||
|
||||
// Pin non-Windows argv; Windows creation adds platform-specific flags.
|
||||
let platformSpy: MockInstance<() => NodeJS.Platform>
|
||||
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReset()
|
||||
translateWslOutputPathsMock.mockClear()
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
platformSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('creates the worktree without touching the local base ref by default', async () => {
|
||||
resolveRemoteBase()
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
resolveCreationBaseConfigWrite()
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 }))
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
|
||||
[
|
||||
[
|
||||
'worktree',
|
||||
'add',
|
||||
'--no-track',
|
||||
'-b',
|
||||
'feature/test',
|
||||
'/repo-feature',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
],
|
||||
[
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'--replace-all',
|
||||
'branch.feature/test.base',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo-feature' }
|
||||
],
|
||||
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }],
|
||||
[['config', '--local', 'push.autoSetupRemote', 'true'], { cwd: '/repo-feature' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('checks out a selected existing local branch without creating a new branch', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
|
||||
checkoutExistingBranch: true
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[
|
||||
['worktree', 'add', '/repo-feature', 'feature/test'],
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('enables long paths for native Windows worktree creation', async () => {
|
||||
platformSpy.mockReturnValue('win32')
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await addWorktree(
|
||||
'C:\\repo',
|
||||
'C:\\repo-feature',
|
||||
'feature/test',
|
||||
'feature/test',
|
||||
false,
|
||||
false,
|
||||
{ checkoutExistingBranch: true }
|
||||
)
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'],
|
||||
{ cwd: 'C:\\repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
|
||||
it('still enables long paths for a Windows-path repo that has a WSL distro configured', async () => {
|
||||
// Host Git may serve C: paths even with a WSL distro; Linux Git ignores core.longpaths.
|
||||
platformSpy.mockReturnValue('win32')
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await addWorktree(
|
||||
'C:\\repo',
|
||||
'C:\\repo-feature',
|
||||
'feature/test',
|
||||
'feature/test',
|
||||
false,
|
||||
false,
|
||||
{ checkoutExistingBranch: true, wslDistro: 'Ubuntu' }
|
||||
)
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'],
|
||||
{ cwd: 'C:\\repo', wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not pass the Windows-only long-path option for a WSL UNC repo path', async () => {
|
||||
platformSpy.mockReturnValue('win32')
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
const repoPath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo'
|
||||
await addWorktree(
|
||||
repoPath,
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature',
|
||||
'feature/test',
|
||||
'feature/test',
|
||||
false,
|
||||
false,
|
||||
{ checkoutExistingBranch: true, wslDistro: 'Ubuntu' }
|
||||
)
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['worktree', 'add', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', 'feature/test'],
|
||||
{ cwd: repoPath, wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux'] as const)(
|
||||
'does not pass the long-path option on %s',
|
||||
async (platform) => {
|
||||
platformSpy.mockReturnValue(platform)
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
|
||||
checkoutExistingBranch: true
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['worktree', 'add', '/repo-feature', 'feature/test'],
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('bounds the worktree add call with a positive timeout (STA-1292 OneDrive stall guard)', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
|
||||
checkoutExistingBranch: true
|
||||
})
|
||||
|
||||
const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find(
|
||||
([argv]) => Array.isArray(argv) && argv.includes('worktree') && argv.includes('add')
|
||||
)
|
||||
expect(worktreeAddCall?.[1]).toMatchObject({ timeout: WORKTREE_ADD_TIMEOUT_MS })
|
||||
expect(WORKTREE_ADD_TIMEOUT_MS).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('raises the worktree add timeout from ORCA_WORKTREE_ADD_TIMEOUT_MS (#12696)', async () => {
|
||||
vi.stubEnv('ORCA_WORKTREE_ADD_TIMEOUT_MS', '600000')
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
|
||||
checkoutExistingBranch: true
|
||||
})
|
||||
|
||||
const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find(
|
||||
([argv]) => Array.isArray(argv) && argv.includes('worktree') && argv.includes('add')
|
||||
)
|
||||
expect(worktreeAddCall?.[1]).toMatchObject({ timeout: 600_000 })
|
||||
})
|
||||
|
||||
it('does not write branch base config when no base branch is provided', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' })
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/no-base')
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[
|
||||
['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'],
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
],
|
||||
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('warns and unsets stale branch base config when persisting the base fails', async () => {
|
||||
resolveRemoteBase()
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked'))
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' })
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await expect(
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
).resolves.toEqual({})
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'addWorktree: failed to set branch.feature/test.base for /repo-feature',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
|
||||
'config',
|
||||
'--local',
|
||||
'--unset-all',
|
||||
'branch.feature/test.base'
|
||||
])
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('warns but does not throw when push.autoSetupRemote config fails', async () => {
|
||||
resolveRemoteBase()
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
resolveCreationBaseConfigWrite()
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 }))
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await expect(
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
).resolves.toEqual({})
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'addWorktree: failed to set push.autoSetupRemote for /repo-feature',
|
||||
expect.any(Error)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('warns and skips --local set when --get fails with non-unset error (e.g. corrupt config)', async () => {
|
||||
// Only exit 1 means unset; other read failures must preserve the user's config.
|
||||
resolveRemoteBase()
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
resolveCreationBaseConfigWrite()
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get fails non-unset
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await expect(
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
).resolves.toEqual({})
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'addWorktree: failed to set push.autoSetupRemote for /repo-feature',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
|
||||
[
|
||||
[
|
||||
'worktree',
|
||||
'add',
|
||||
'--no-track',
|
||||
'-b',
|
||||
'feature/test',
|
||||
'/repo-feature',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
],
|
||||
[
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'--replace-all',
|
||||
'branch.feature/test.base',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo-feature' }
|
||||
],
|
||||
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
|
||||
])
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('preserves existing push.autoSetupRemote value (does not overwrite user-set false)', async () => {
|
||||
resolveRemoteBase()
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
resolveCreationBaseConfigWrite()
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'false\n' })
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
|
||||
[
|
||||
[
|
||||
'worktree',
|
||||
'add',
|
||||
'--no-track',
|
||||
'-b',
|
||||
'feature/test',
|
||||
'/repo-feature',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
],
|
||||
[
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'--replace-all',
|
||||
'branch.feature/test.base',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo-feature' }
|
||||
],
|
||||
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('treats --get success with empty stdout as "already set" (key present but blank)', async () => {
|
||||
resolveRemoteBase()
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
resolveCreationBaseConfigWrite()
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
|
||||
[
|
||||
[
|
||||
'worktree',
|
||||
'add',
|
||||
'--no-track',
|
||||
'-b',
|
||||
'feature/test',
|
||||
'/repo-feature',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
],
|
||||
[
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'--replace-all',
|
||||
'branch.feature/test.base',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo-feature' }
|
||||
],
|
||||
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not write config when worktree add itself fails', async () => {
|
||||
resolveRemoteBase()
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('worktree add failed'))
|
||||
|
||||
await expect(
|
||||
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
|
||||
).rejects.toThrow('worktree add failed')
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
|
||||
[
|
||||
[
|
||||
'worktree',
|
||||
'add',
|
||||
'--no-track',
|
||||
'-b',
|
||||
'feature/test',
|
||||
'/repo-feature',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('qualifies bare branch name as refs/heads/ when a same-named tag exists', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
resolveCreationBaseConfigWrite()
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 }))
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/disambig', 'main')
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls[0]).toEqual([
|
||||
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
|
||||
{ cwd: '/repo' }
|
||||
])
|
||||
expect(gitExecFileAsyncMock.mock.calls[1]).toEqual([
|
||||
[
|
||||
'worktree',
|
||||
'add',
|
||||
'--no-track',
|
||||
'-b',
|
||||
'feature/disambig',
|
||||
'/repo-feature',
|
||||
'refs/heads/main'
|
||||
],
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not report a local base refresh for slash-containing local branch names', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('no remote ref'))
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
resolveCreationBaseConfigWrite()
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 }))
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
const result = await addWorktree(
|
||||
'/repo',
|
||||
'/repo-feature',
|
||||
'feature/release',
|
||||
'release/main',
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/release/main^{commit}'],
|
||||
['rev-parse', '--verify', '--quiet', 'refs/heads/release/main^{commit}'],
|
||||
[
|
||||
'worktree',
|
||||
'add',
|
||||
'--no-track',
|
||||
'-b',
|
||||
'feature/release',
|
||||
'/repo-feature',
|
||||
'refs/heads/release/main'
|
||||
],
|
||||
[
|
||||
'config',
|
||||
'--local',
|
||||
'--replace-all',
|
||||
'branch.feature/release.base',
|
||||
'refs/heads/release/main'
|
||||
],
|
||||
['config', '--get', 'push.autoSetupRemote'],
|
||||
['config', '--local', 'push.autoSetupRemote', 'true']
|
||||
])
|
||||
})
|
||||
|
||||
it('unsets branch base config during sparse setup cleanup after creation succeeds', async () => {
|
||||
const beforeRemoval =
|
||||
'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n'
|
||||
resolveRemoteBase()
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
resolveCreationBaseConfigWrite()
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' })
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('sparse setup failed'))
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await expect(
|
||||
addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['src'], 'origin/main')
|
||||
).rejects.toThrow('sparse setup failed')
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
|
||||
'config',
|
||||
'--local',
|
||||
'--unset-all',
|
||||
'branch.feature/test.base'
|
||||
])
|
||||
// Rollback may force-delete its new branch; user-initiated deletion must protect commits.
|
||||
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
|
||||
'branch',
|
||||
'-D',
|
||||
'--',
|
||||
'feature/test'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -118,20 +118,6 @@ describe('Hermes YAML document edits', () => {
|
||||
expect(output).toContain('shared_env: *env')
|
||||
})
|
||||
|
||||
it('keeps folded scalars, quoted strings and long lines in a real update', () => {
|
||||
const input = `# header\nprompt: >-\n First line\n Second line\nquoted: 'off'\nlong: ${'word '
|
||||
.repeat(30)
|
||||
.trimEnd()}\nplugins: {enabled: [other]}\n`
|
||||
const output = updateConfigContent(input, enablePlugin).content ?? ''
|
||||
expect(output).toContain('prompt: >-\n First line\n Second line\n')
|
||||
expect(output).toContain("quoted: 'off'")
|
||||
expect(output).toContain(`long: ${'word '.repeat(30).trimEnd()}\n`)
|
||||
expect(parse(output)).toEqual({
|
||||
...parse(input),
|
||||
plugins: { enabled: ['other', 'orca-status'] }
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['\n', '\r\n'])(
|
||||
'leaves unrelated source bytes intact with unusual indentation (%j)',
|
||||
(eol) => {
|
||||
|
||||
@@ -177,41 +177,6 @@ describe('registerEphemeralVmHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lists the recipe catalog across local git repos', async () => {
|
||||
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
|
||||
writeFileSync(
|
||||
join(repoPath, 'orca.yaml'),
|
||||
[
|
||||
'environmentRecipes:',
|
||||
' - id: cloud-sandbox',
|
||||
' name: Cloud Sandbox',
|
||||
' create: ./scripts/start.sh',
|
||||
' destroy: none'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const store = makeStore(repoPath)
|
||||
registerEphemeralVmHandlers(store as never)
|
||||
const result = await handlers.get('ephemeralVm:listRecipeCatalog')?.(null, undefined as never)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
repoPath,
|
||||
diagnostics: [],
|
||||
recipes: [
|
||||
{
|
||||
id: 'cloud-sandbox',
|
||||
name: 'Cloud Sandbox',
|
||||
create: './scripts/start.sh',
|
||||
destroyDisabled: true
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('merges approved plugin recipes while repository recipes shadow matching ids', async () => {
|
||||
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
|
||||
writeFileSync(
|
||||
@@ -714,53 +679,6 @@ describe('registerEphemeralVmHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a copyable cleanup command for a persisted runtime', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
|
||||
getPathMock.mockReturnValue(userDataPath)
|
||||
mkdirSync(join(repoPath, 'scripts'), { recursive: true })
|
||||
const startPath = join(repoPath, 'scripts', 'start.js')
|
||||
const cleanupPath = join(repoPath, 'scripts', 'cleanup.js')
|
||||
writeFileSync(
|
||||
startPath,
|
||||
[
|
||||
'console.log(JSON.stringify({',
|
||||
' schemaVersion: 1,',
|
||||
` pairingCode: ${JSON.stringify(makePairingCode())},`,
|
||||
" projectRoot: '/workspace/repo'",
|
||||
'}))'
|
||||
].join('\n')
|
||||
)
|
||||
writeFileSync(cleanupPath, 'process.stdin.resume()\n')
|
||||
writeFileSync(
|
||||
join(repoPath, 'orca.yaml'),
|
||||
[
|
||||
'environmentRecipes:',
|
||||
' - id: cloud-sandbox',
|
||||
' name: Cloud Sandbox',
|
||||
` create: ${JSON.stringify(nodeCommand(startPath))}`,
|
||||
` destroy: ${JSON.stringify(nodeCommand(cleanupPath))}`
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
registerEphemeralVmHandlers(makeStore(repoPath) as never)
|
||||
const provisioned = (await handlers.get('ephemeralVm:provision')?.(null, {
|
||||
repoId: 'repo-1',
|
||||
recipeId: 'cloud-sandbox',
|
||||
workspaceName: 'Fix Login Race'
|
||||
} as never)) as { ok: true; runtime: { id: string } }
|
||||
const result = await handlers.get('ephemeralVm:getCleanupCommand')?.(null, {
|
||||
runtimeId: provisioned.runtime.id
|
||||
} as never)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
runtimeId: provisioned.runtime.id,
|
||||
cleanupDisabled: false,
|
||||
payloadJson: expect.stringContaining('"workspaceName": "Fix Login Race"'),
|
||||
command: expect.stringContaining(nodeCommand(cleanupPath))
|
||||
})
|
||||
})
|
||||
|
||||
it('streams provision logs and cancels an active provision', async () => {
|
||||
const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-')
|
||||
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
|
||||
|
||||
@@ -99,10 +99,7 @@ vi.mock('../gitea/client', () => ({
|
||||
getGiteaAuthStatus: getGiteaAuthStatusMock
|
||||
}))
|
||||
|
||||
import {
|
||||
detectInstalledAgents,
|
||||
detectInstalledAgentsWithShellPathHydration
|
||||
} from '../preflight/agent-detection'
|
||||
import { detectInstalledAgents } from '../preflight/agent-detection'
|
||||
import { registerPreflightHandlers } from './preflight'
|
||||
import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture'
|
||||
import { resetPreflightMocks, type HandlerMap } from './preflight-test-harness'
|
||||
@@ -207,128 +204,6 @@ describe('preflight', () => {
|
||||
expect(isCommandOnLocalPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only reports agents when which/where resolves to a real executable path', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
|
||||
const target = String(args[0])
|
||||
if (target === 'claude') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/Users/test/.local/bin/claude\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
if (target === 'continue') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: 'continue: shell built-in command\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
if (target === 'cursor-agent') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/Users/test/.local/bin/cursor-agent\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'cursor'])
|
||||
})
|
||||
|
||||
it('does not report Claude Agent Teams when only the Orca shim is present', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'orca') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/Applications/Orca.app/Contents/MacOS/orca\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('reports Claude Agent Teams when both Orca and Claude are present', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'claude') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/Users/test/.local/bin/claude\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
if (String(args[0]) === 'orca') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/Applications/Orca.app/Contents/MacOS/orca\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'claude-agent-teams'])
|
||||
})
|
||||
|
||||
it('does not report Claude Agent Teams on native Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'where') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'claude') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/mock/windows/npm/claude.cmd\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
if (String(args[0]) === 'orca') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/mock/windows/programs/orca.cmd\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents()).resolves.toEqual(['claude'])
|
||||
})
|
||||
|
||||
it('detects agents via the install-dir resolver when which fails (stripped GUI PATH)', async () => {
|
||||
// Cold GUI detection can precede shell-PATH hydration; install directories remain a fallback.
|
||||
execFileAsyncMock.mockImplementation(async (command) => {
|
||||
@@ -359,33 +234,6 @@ describe('preflight', () => {
|
||||
expect(resolveCliCommandsMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not double-count an agent already found on PATH via the install-dir resolver', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'claude') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/Users/test/.local/bin/claude\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
resolveCliCommandsMock.mockImplementation(
|
||||
(commands: string[]) => new Map(commands.map((cmd) => [cmd, cmd]))
|
||||
)
|
||||
|
||||
await expect(detectInstalledAgents()).resolves.toEqual(['claude'])
|
||||
expect(resolveCliCommandsMock).toHaveBeenCalledTimes(1)
|
||||
expect(resolveCliCommandsMock).toHaveBeenCalledWith(expect.not.arrayContaining(['claude']), {
|
||||
skipPathSearch: true
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an agent as not installed when the install-dir resolver throws', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command) => {
|
||||
if (command !== 'which') {
|
||||
@@ -400,160 +248,6 @@ describe('preflight', () => {
|
||||
await expect(detectInstalledAgents()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('registers agent detection through the shared launch config commands', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'openclaude') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/Users/test/.local/bin/openclaude\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
if (String(args[0]) === 'cursor-agent') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/Users/test/.local/bin/cursor-agent\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
registerPreflightHandlers()
|
||||
|
||||
await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor'])
|
||||
})
|
||||
|
||||
it('hydrates shell PATH before user-facing agent detection', async () => {
|
||||
const originalPath = process.env.PATH
|
||||
process.env.PATH = '/usr/bin'
|
||||
hydrateShellPathMock.mockResolvedValueOnce({
|
||||
segments: ['/home/test/.local/bin'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
})
|
||||
mergePathSegmentsMock.mockImplementationOnce((segments: string[]) => {
|
||||
process.env.PATH = [...segments, '/usr/bin'].join(':')
|
||||
return segments
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/home/test/.local/bin/codex\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(detectInstalledAgentsWithShellPathHydration()).resolves.toEqual(['codex'])
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = originalPath
|
||||
}
|
||||
}
|
||||
expect(hydrateShellPathMock).toHaveBeenCalledWith()
|
||||
expect(mergePathSegmentsMock).toHaveBeenCalledWith(['/home/test/.local/bin'])
|
||||
})
|
||||
|
||||
it('does not run host shell hydration for WSL agent detection', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => {
|
||||
if (script.includes("'claude'")) {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(
|
||||
detectInstalledAgentsWithShellPathHydration({ wslDistro: 'Ubuntu' })
|
||||
).resolves.toEqual(['claude'])
|
||||
expect(hydrateShellPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not report Claude Agent Teams from WSL agent detection', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => {
|
||||
expect(script).not.toContain("'orca'")
|
||||
expect(script).not.toContain("'orca-dev'")
|
||||
expect(script).not.toContain("'orca-ide'")
|
||||
if (script.includes("'claude'")) {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude'])
|
||||
})
|
||||
|
||||
it('detects Mistral Vibe from the installed vibe executable', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'vibe') {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '/home/test/.local/bin/vibe\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents()).resolves.toEqual(['mistral-vibe'])
|
||||
})
|
||||
|
||||
it('deduplicates Mistral Vibe when both current and legacy executables exist', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'vibe' || String(args[0]) === 'mistral-vibe') {
|
||||
return { stdout: `/home/test/.local/bin/${String(args[0])}\n` }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents()).resolves.toEqual(['mistral-vibe'])
|
||||
})
|
||||
|
||||
it('detects agents from the selected WSL distro for a WSL workspace', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
|
||||
@@ -2,18 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
listWorktreesMock,
|
||||
addWorktreeMock,
|
||||
getBranchConflictKindMock,
|
||||
getPRForBranchMock,
|
||||
getHostedReviewForBranchMock,
|
||||
computeWorktreePathMock,
|
||||
gitExecFileAsyncMock
|
||||
} from './worktrees-test-module-mocks'
|
||||
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
||||
import { handlers, setupWorktreeHandlers } from './worktrees-test-harness'
|
||||
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
|
||||
import {
|
||||
hostWorktreeTarget,
|
||||
type HostWorktreeTarget
|
||||
} from '../../shared/worktree/host-qualified-identity'
|
||||
|
||||
vi.mock('electron', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).electronModuleMock()
|
||||
@@ -99,120 +93,6 @@ describe('registerWorktreeHandlers', () => {
|
||||
runtimeStub = setupWorktreeHandlers()
|
||||
})
|
||||
|
||||
it('checks out a selected existing local branch exactly', async () => {
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/fix-bug-0',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/fix/bug-0',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'fix/bug-0',
|
||||
baseBranch: 'fix/bug-0',
|
||||
branchNameOverride: 'fix/bug-0'
|
||||
})
|
||||
|
||||
expect(getBranchConflictKindMock).not.toHaveBeenCalled()
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/fix-bug-0',
|
||||
'fix/bug-0',
|
||||
'fix/bug-0',
|
||||
false,
|
||||
false,
|
||||
{ checkoutExistingBranch: true }
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: 'repo-1::/workspace/fix-bug-0', hostId: 'local' }),
|
||||
expect.objectContaining({ preserveBranchOnDelete: true })
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
path: '/workspace/fix-bug-0',
|
||||
branch: 'refs/heads/fix/bug-0'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses an existing local branch when the worktree folder is renamed (#5181)', async () => {
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/my-folder',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/fix/bug-0',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'my-folder',
|
||||
baseBranch: 'fix/bug-0',
|
||||
branchNameOverride: 'fix/bug-0'
|
||||
})
|
||||
|
||||
expect(getBranchConflictKindMock).not.toHaveBeenCalled()
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/my-folder',
|
||||
'fix/bug-0',
|
||||
'fix/bug-0',
|
||||
false,
|
||||
false,
|
||||
{ checkoutExistingBranch: true }
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: 'repo-1::/workspace/my-folder', hostId: 'local' }),
|
||||
expect.objectContaining({ preserveBranchOnDelete: true })
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
path: '/workspace/my-folder',
|
||||
branch: 'refs/heads/fix/bug-0'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['remote', 'local', 'busy'])('preserves explicit picker intent (%s)', async (kind) => {
|
||||
const branchName = 'fix/bug-0'
|
||||
const remoteTrackingBase = {
|
||||
@@ -287,558 +167,4 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
expect(result).toMatchObject({ worktree: { branch: `refs/heads/${branchName}` } })
|
||||
})
|
||||
|
||||
it.each([undefined, 'open-existing'] as const)(
|
||||
'suffixes a divergent local branch collision for intent %s',
|
||||
async (branchIntent) => {
|
||||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||||
branch === 'feature/something' ? 'local' : null
|
||||
)
|
||||
gitExecFileAsyncMock.mockImplementation(async (argv) => ({
|
||||
stdout: argv.some((arg) => arg.startsWith('refs/heads/')) ? 'local-tip' : 'remote-tip',
|
||||
stderr: ''
|
||||
}))
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/feature-something-2',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/something-2',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature/something',
|
||||
branchNameOverride: 'feature/something',
|
||||
branchIntent
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['check-ref-format', '--branch', 'feature/something-2'],
|
||||
{ cwd: '/workspace/repo' }
|
||||
)
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/feature-something-2',
|
||||
'feature/something-2',
|
||||
'origin/main',
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('allows a resolver-provided PR branch override to match its remote push target', async () => {
|
||||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||||
branch === 'feature/fix' ? 'remote' : null
|
||||
)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/fix-title',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/fix',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta)
|
||||
getPRForBranchMock.mockResolvedValueOnce({
|
||||
number: 42,
|
||||
title: 'Selected PR',
|
||||
state: 'open',
|
||||
url: 'https://example.com/pr/42',
|
||||
checksStatus: 'success',
|
||||
updatedAt: '2026-05-21T00:00:00Z',
|
||||
mergeable: 'UNKNOWN'
|
||||
})
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'fix-title',
|
||||
baseBranch: 'abc123',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
branchNameOverride: 'feature/fix',
|
||||
linkedPR: 42,
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
})
|
||||
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/fix-title',
|
||||
'feature/fix',
|
||||
'abc123',
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['branch', '--set-upstream-to', 'origin/feature/fix', 'feature/fix'],
|
||||
{ cwd: '/workspace/fix-title' }
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: 'repo-1::/workspace/fix-title', hostId: 'local' }),
|
||||
expect.objectContaining({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
linkedPR: 42
|
||||
})
|
||||
)
|
||||
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix')
|
||||
})
|
||||
|
||||
it('persists an explicit compare base ahead of the checkout remote-tracking base', async () => {
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValueOnce({
|
||||
base: 'origin/source-branch',
|
||||
remote: 'origin',
|
||||
branch: 'source-branch',
|
||||
ref: 'refs/remotes/origin/source-branch'
|
||||
})
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValueOnce(true)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/fix-title',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/fix',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta)
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'fix-title',
|
||||
baseBranch: 'origin/source-branch',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
branchNameOverride: 'feature/fix',
|
||||
linkedGitLabMR: 7,
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: 'repo-1::/workspace/fix-title', hostId: 'local' }),
|
||||
expect.objectContaining({
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
linkedGitLabMR: 7
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('allows a selected Bitbucket PR branch override to match its remote push target', async () => {
|
||||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||||
branch === 'feature/bitbucket' ? 'remote' : null
|
||||
)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/bitbucket-title',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/bitbucket',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.setWorktreeMeta.mockImplementation((_target: HostWorktreeTarget, meta) => meta)
|
||||
getHostedReviewForBranchMock.mockResolvedValueOnce({
|
||||
provider: 'bitbucket',
|
||||
number: 11,
|
||||
title: 'Bitbucket PR',
|
||||
state: 'open',
|
||||
url: 'https://bitbucket.org/team/repo/pull-requests/11',
|
||||
status: 'success',
|
||||
updatedAt: '2026-05-21T00:00:00Z',
|
||||
mergeable: 'UNKNOWN'
|
||||
})
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'bitbucket-title',
|
||||
baseBranch: 'abc123',
|
||||
branchNameOverride: 'feature/bitbucket',
|
||||
linkedBitbucketPR: 11,
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/bitbucket' }
|
||||
})
|
||||
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/bitbucket-title',
|
||||
'feature/bitbucket',
|
||||
'abc123',
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: 'repo-1::/workspace/bitbucket-title', hostId: 'local' }),
|
||||
expect.objectContaining({ linkedBitbucketPR: 11 })
|
||||
)
|
||||
expect(getHostedReviewForBranchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoPath: '/workspace/repo',
|
||||
branch: 'feature/bitbucket',
|
||||
linkedBitbucketPR: 11
|
||||
})
|
||||
)
|
||||
expect(getPRForBranchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suffixes a selected Bitbucket PR branch when the existing PR is different', async () => {
|
||||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||||
branch === 'feature/bitbucket' ? 'remote' : null
|
||||
)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/bitbucket-title-2',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/bitbucket-2',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
getHostedReviewForBranchMock.mockResolvedValueOnce({
|
||||
provider: 'bitbucket',
|
||||
number: 12,
|
||||
title: 'Different Bitbucket PR',
|
||||
state: 'open',
|
||||
url: 'https://bitbucket.org/team/repo/pull-requests/12',
|
||||
status: 'success',
|
||||
updatedAt: '2026-05-21T00:00:00Z',
|
||||
mergeable: 'UNKNOWN'
|
||||
})
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'bitbucket-title',
|
||||
baseBranch: 'abc123',
|
||||
branchNameOverride: 'feature/bitbucket',
|
||||
linkedBitbucketPR: 11,
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/bitbucket' }
|
||||
})
|
||||
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/bitbucket-title-2',
|
||||
'feature/bitbucket-2',
|
||||
'abc123',
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: 'repo-1::/workspace/bitbucket-title-2', hostId: 'local' }),
|
||||
expect.objectContaining({ linkedBitbucketPR: 11 })
|
||||
)
|
||||
})
|
||||
|
||||
it('suffixes a matching push target branch without selected PR metadata', async () => {
|
||||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||||
branch === 'feature/fix' ? 'remote' : null
|
||||
)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/fix-title-2',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/fix-2',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'fix-title',
|
||||
baseBranch: 'abc123',
|
||||
branchNameOverride: 'feature/fix',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
})
|
||||
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/fix-title-2',
|
||||
'feature/fix-2',
|
||||
'abc123',
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('suffixes a matching push target branch when selected PR metadata has no PR number', async () => {
|
||||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||||
branch === 'feature/fix' ? 'remote' : null
|
||||
)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/fix-title-2',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/fix-2',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'fix-title',
|
||||
baseBranch: 'abc123',
|
||||
branchNameOverride: 'feature/fix',
|
||||
linkedPR: null,
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
})
|
||||
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/fix-title-2',
|
||||
'feature/fix-2',
|
||||
'abc123',
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('suffixes a matching push target branch when the existing PR is different', async () => {
|
||||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||||
branch === 'feature/fix' ? 'remote' : null
|
||||
)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/fix-title-2',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/fix-2',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
getPRForBranchMock.mockResolvedValueOnce({
|
||||
number: 43,
|
||||
title: 'Different PR',
|
||||
state: 'open',
|
||||
url: 'https://example.com/pr/43',
|
||||
checksStatus: 'success',
|
||||
updatedAt: '2026-05-21T00:00:00Z',
|
||||
mergeable: 'UNKNOWN'
|
||||
})
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'fix-title',
|
||||
baseBranch: 'abc123',
|
||||
branchNameOverride: 'feature/fix',
|
||||
linkedPR: 42,
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
})
|
||||
|
||||
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix')
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/fix-title-2',
|
||||
'feature/fix-2',
|
||||
'abc123',
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('suffixes a selected PR remote conflict when the PR lookup fails', async () => {
|
||||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||||
branch === 'feature/fix' ? 'remote' : null
|
||||
)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/fix-title-2',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/fix-2',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
getPRForBranchMock.mockRejectedValueOnce(new Error('gh unavailable'))
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'fix-title',
|
||||
baseBranch: 'abc123',
|
||||
branchNameOverride: 'feature/fix',
|
||||
linkedPR: 42,
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
})
|
||||
|
||||
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix')
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/fix-title-2',
|
||||
'feature/fix-2',
|
||||
'abc123',
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('checks out an unused existing PR branch only when it is at the resolved head SHA', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
|
||||
return { stdout: 'abc123\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) {
|
||||
return { stdout: 'abc123\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/fix-title',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/fix',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'fix-title',
|
||||
baseBranch: 'abc123',
|
||||
branchNameOverride: 'feature/fix'
|
||||
})
|
||||
|
||||
expect(getBranchConflictKindMock).not.toHaveBeenCalled()
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/fix-title',
|
||||
'feature/fix',
|
||||
'abc123',
|
||||
false,
|
||||
false,
|
||||
{ checkoutExistingBranch: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('suffixes an existing PR branch when its tip differs from the resolved head SHA', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
|
||||
return { stdout: 'old123\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) {
|
||||
return { stdout: 'abc123\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
getBranchConflictKindMock.mockResolvedValueOnce('local')
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/fix-title-2',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/fix-2',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'fix-title',
|
||||
baseBranch: 'abc123',
|
||||
branchNameOverride: 'feature/fix'
|
||||
})
|
||||
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/fix-title-2',
|
||||
'feature/fix-2',
|
||||
'abc123',
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('skips past a suffix that already belongs to a PR after an initial branch conflict', async () => {
|
||||
// Network PR probes start only after the first branch collision, preserving the fast happy path.
|
||||
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||||
branch === 'improve-dashboard' ? 'remote' : null
|
||||
)
|
||||
getPRForBranchMock.mockImplementation(async (_repoPath: string, branch: string) =>
|
||||
branch === 'improve-dashboard-2'
|
||||
? {
|
||||
number: 3127,
|
||||
title: 'Existing PR',
|
||||
state: 'merged',
|
||||
url: 'https://example.com/pr/3127',
|
||||
checksStatus: 'success',
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
mergeable: 'UNKNOWN'
|
||||
}
|
||||
: null
|
||||
)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/improve-dashboard-3',
|
||||
head: 'abc123',
|
||||
branch: 'improve-dashboard-3',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/improve-dashboard-3',
|
||||
'improve-dashboard-3',
|
||||
'origin/main',
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
path: '/workspace/improve-dashboard-3',
|
||||
branch: 'improve-dashboard-3'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call `gh pr list` on the happy path (no branch conflict)', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'abc123',
|
||||
branch: 'improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
|
||||
expect(getPRForBranchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,9 +15,7 @@ import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import type { RedactableSpan } from '../observability/redactor'
|
||||
import type { Store } from '../persistence/loading-store/store'
|
||||
import { _resetTracerForTests, setActiveSink } from '../observability/tracer'
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import {
|
||||
@@ -32,10 +30,7 @@ import {
|
||||
advertisedUrlWatcherForgetWorktreeMock,
|
||||
pruneCleanupScanSnapshotMock,
|
||||
pruneSpaceAnalysisSnapshotMock,
|
||||
recordRemovalSnapshotPruneMock,
|
||||
killAllProcessesForWorktreeMock,
|
||||
clearProviderPtyStateMock,
|
||||
getLocalPtyProviderMock
|
||||
killAllProcessesForWorktreeMock
|
||||
} from './worktrees-test-module-mocks'
|
||||
import {
|
||||
handlers,
|
||||
@@ -187,22 +182,6 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('globally invalidates when the removed repo has no registered authorization owner', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
store.getRepos.mockReturnValue([])
|
||||
getEffectiveHooksMock.mockReturnValue(null)
|
||||
removeWorktreeMock.mockResolvedValue({})
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' })
|
||||
|
||||
expect(scopedRootsInvalidation.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledOnce()
|
||||
expect(scopedRootsInvalidation.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledWith(
|
||||
store,
|
||||
'repo-1'
|
||||
)
|
||||
expect(authorizedRootsCache.invalidateAuthorizedRootsCache).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('purges only the selected host when a normal worktree id is owned locally and over SSH', async () => {
|
||||
const worktreeId = 'repo-1::/workspace/feature-wt'
|
||||
const localRepo = {
|
||||
@@ -254,123 +233,6 @@ describe('registerWorktreeHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
getEffectiveHooksMock.mockReturnValue(null)
|
||||
removeWorktreeMock.mockResolvedValue({})
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
snapshotPruneBatchId: 'cleanup-batch-1'
|
||||
})
|
||||
|
||||
expect(recordRemovalSnapshotPruneMock).toHaveBeenCalledExactlyOnceWith('/profile-a', {
|
||||
batchId: 'cleanup-batch-1',
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
expect(pruneCleanupScanSnapshotMock).not.toHaveBeenCalled()
|
||||
expect(pruneSpaceAnalysisSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('traces the removal as worktree.remove with a stage sub-span tree', async () => {
|
||||
const records: RedactableSpan[] = []
|
||||
setActiveSink({
|
||||
push: (record) => records.push(record as RedactableSpan),
|
||||
flush: () => {},
|
||||
close: () => {}
|
||||
})
|
||||
try {
|
||||
mockKnownFeatureWorktree()
|
||||
getEffectiveHooksMock.mockReturnValue(null)
|
||||
removeWorktreeMock.mockResolvedValue({})
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' })
|
||||
|
||||
const parent = records.find((record) => record.name === 'worktree.remove')
|
||||
expect(parent).toBeDefined()
|
||||
expect(parent?.attributes).toMatchObject({
|
||||
kind: 'worktree',
|
||||
'worktree.stage': 'remove',
|
||||
'worktree.path': '/workspace/feature-wt'
|
||||
})
|
||||
const stages = records.filter((record) => record.name.startsWith('worktree.remove.'))
|
||||
expect(stages.map((record) => record.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'worktree.remove.watcher_gate',
|
||||
'worktree.remove.pty_sweep',
|
||||
'worktree.remove.git_remove',
|
||||
'worktree.remove.metadata_purge',
|
||||
'worktree.remove.cache_invalidation'
|
||||
])
|
||||
)
|
||||
// Parent spans make removal-stage freezes attributable.
|
||||
for (const stage of stages) {
|
||||
expect(stage.parentSpanId).toBe(parent?.spanId)
|
||||
expect(stage.attributes).toMatchObject({ kind: 'worktree', 'worktree.flow': 'local' })
|
||||
}
|
||||
} finally {
|
||||
_resetTracerForTests()
|
||||
}
|
||||
})
|
||||
|
||||
it('traces a local archive hook as flow local, not remote', async () => {
|
||||
const records: RedactableSpan[] = []
|
||||
setActiveSink({
|
||||
push: (record) => records.push(record as RedactableSpan),
|
||||
flush: () => {},
|
||||
close: () => {}
|
||||
})
|
||||
try {
|
||||
mockKnownFeatureWorktree()
|
||||
getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'pnpm worktree:archive' } })
|
||||
runHookMock.mockResolvedValue({ success: true, output: '' })
|
||||
removeWorktreeMock.mockResolvedValue({})
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' })
|
||||
|
||||
const archiveStage = records.find((record) => record.name === 'worktree.remove.archive_hook')
|
||||
expect(archiveStage?.attributes).toMatchObject({
|
||||
kind: 'worktree',
|
||||
'worktree.flow': 'local'
|
||||
})
|
||||
} finally {
|
||||
_resetTracerForTests()
|
||||
}
|
||||
})
|
||||
|
||||
it('prunes git worktree tracking when removing an orphaned worktree', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
const orphanError = Object.assign(new Error('git worktree remove failed'), {
|
||||
stderr: "fatal: '/workspace/feature-wt' is not a working tree"
|
||||
})
|
||||
removeWorktreeMock.mockRejectedValue(orphanError)
|
||||
getEffectiveHooksMock.mockReturnValue(null)
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||||
})
|
||||
|
||||
expect(scopedRootsInvalidation.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledOnce()
|
||||
expect(scopedRootsInvalidation.invalidateAuthorizedRootsCacheForRepo).toHaveBeenCalledWith(
|
||||
store,
|
||||
'repo-1'
|
||||
)
|
||||
expect(authorizedRootsCache.invalidateAuthorizedRootsCache).not.toHaveBeenCalled()
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||||
cwd: '/workspace/repo'
|
||||
})
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: 'repo-1::/workspace/feature-wt', hostId: 'local' })
|
||||
)
|
||||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith('repo-1::/workspace/feature-wt')
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['unproven', 'removal-fails'] as const)(
|
||||
'keeps desktop orphan cleanup retryable when the directory is %s',
|
||||
async (mode) => {
|
||||
@@ -570,135 +432,6 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kills PTYs before removing additional folder workspace metadata', async () => {
|
||||
const ptyProvider = {} as never
|
||||
const worktreeId = 'repo-folder::/workspace/folder::workspace:child-1'
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-folder',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
})
|
||||
getLocalPtyProviderMock.mockReturnValue(ptyProvider)
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId })
|
||||
|
||||
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, {
|
||||
runtime: runtimeStub,
|
||||
resolvedWorktreeId: worktreeId,
|
||||
localProvider: ptyProvider,
|
||||
onPtyStopped: clearProviderPtyStateMock,
|
||||
// Structured sessions are invisible to PTY inventories, so folder removal closes them separately.
|
||||
closeStructuredSessions: true
|
||||
})
|
||||
expect(killAllProcessesForWorktreeMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
store.removeWorktreeMeta.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: worktreeId, hostId: 'local' })
|
||||
)
|
||||
expect(advertisedUrlWatcherForgetWorktreeMock).toHaveBeenCalledWith(worktreeId)
|
||||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-folder'
|
||||
})
|
||||
})
|
||||
|
||||
it('purges a folder workspace after a rejected best-effort PTY sweep', async () => {
|
||||
const worktreeId = 'repo-folder::/workspace/folder::workspace:child-1'
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-folder',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
})
|
||||
killAllProcessesForWorktreeMock.mockRejectedValue(new Error('terminal inventory unavailable'))
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId })
|
||||
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: worktreeId, hostId: 'local' })
|
||||
)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-folder'
|
||||
})
|
||||
expect(store.removeWorktreeMeta.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mainWindow.webContents.send.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
// Folder workspace IDs repeat across hosts; fence teardown to the owning connection.
|
||||
it('fences an SSH folder workspace PTY sweep to the owning connection', async () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an identity marker; the mocked sweep never calls into it.
|
||||
const localPtyProvider = { id: 'local-pty-provider' } as never
|
||||
const worktreeId = 'repo-folder::/remote/folder::workspace:child-1'
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-folder',
|
||||
path: '/remote/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder',
|
||||
connectionId: 'conn-1'
|
||||
})
|
||||
getLocalPtyProviderMock.mockReturnValue(localPtyProvider)
|
||||
// Same-ID local metadata must not override the resolved SSH repo owner.
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ hostId: 'local' }))
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId })
|
||||
|
||||
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, {
|
||||
runtime: runtimeStub,
|
||||
resolvedWorktreeId: worktreeId,
|
||||
resolvedConnectionId: 'conn-1',
|
||||
localProvider: localPtyProvider,
|
||||
onPtyStopped: clearProviderPtyStateMock,
|
||||
includeProviderInventory: false,
|
||||
includeLocalRegistry: false,
|
||||
closeStructuredSessions: true
|
||||
})
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: worktreeId, hostId: 'ssh:conn-1' })
|
||||
)
|
||||
expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fences a mirrored runtime folder workspace sweep to its environment', async () => {
|
||||
const runtimePtyProvider = {} as never
|
||||
const worktreeId = 'repo-folder::/runtime/folder::workspace:child-1'
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-folder',
|
||||
path: '/runtime/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
getLocalPtyProviderMock.mockReturnValue(runtimePtyProvider)
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId,
|
||||
hostId: 'runtime:env-1'
|
||||
})
|
||||
|
||||
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, {
|
||||
runtime: runtimeStub,
|
||||
resolvedWorktreeId: worktreeId,
|
||||
resolvedRuntimeEnvironmentId: 'env-1',
|
||||
localProvider: runtimePtyProvider,
|
||||
onPtyStopped: clearProviderPtyStateMock,
|
||||
includeProviderInventory: false,
|
||||
includeLocalRegistry: false,
|
||||
closeStructuredSessions: true
|
||||
})
|
||||
})
|
||||
// Authoritative off-host removal must retire status rows too; local GC cannot reach them.
|
||||
it("retires the scan-proven host rows from the agent status store, and only that host's", async () => {
|
||||
const worktreeId = 'repo-1::/remote/deleted'
|
||||
|
||||
@@ -231,54 +231,6 @@ describe('WSL move orchestrator wiring', () => {
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('moves a distro whose host is ready at start, with the window slept panes', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
await answerWindow()
|
||||
// The automatic mover's preview settles a move an update or crash interrupted, finishing at restart.
|
||||
expect(driver.previewWslMove).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ distro: 'Ubuntu', sleptPanes },
|
||||
{ settleInterrupted: 'finish-at-restart' }
|
||||
)
|
||||
expect(driver.moveProjectsToWslHost).toHaveBeenCalledWith(expect.anything(), {
|
||||
distro: 'Ubuntu',
|
||||
sleptPanes
|
||||
})
|
||||
})
|
||||
|
||||
it('starts on a host reaching ready, never on one that is not', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'connecting', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
await answerWindow()
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
for (const listener of host.listeners) {
|
||||
listener()
|
||||
}
|
||||
await answerWindow()
|
||||
expect(driver.moveProjectsToWslHost).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('makes no automatic start with ORCA_WSL_MOVE_AUTO=0', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
vi.stubEnv('ORCA_WSL_MOVE_AUTO', '0')
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
await answerWindow()
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A block published while Orca runs stops every new move at once: automatic, opened or Retry.
|
||||
it('starts nothing after a live policy block, from readiness, a project open or Retry', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
@@ -325,55 +277,6 @@ describe('WSL move orchestrator wiring', () => {
|
||||
expect(driver.moveProjectsToWslHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a slept-pane answer from another window', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
const request = sent.find(
|
||||
(message) => message.channel === WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL
|
||||
)
|
||||
const requestId = requestIdOf(request?.payload)
|
||||
await handlers.get('wslMoveOrchestrator:answerSleptPanes')?.(
|
||||
{ sender: { id: 99 } },
|
||||
{ requestId, sleptPanes }
|
||||
)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('T2: a bridge project opened on a running distro adds its host and moves it', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
await register()
|
||||
await handlers.get('wslMoveOrchestrator:projectOpened')?.(
|
||||
{ sender: { id: MAIN_WINDOW_ID } },
|
||||
'Ubuntu'
|
||||
)
|
||||
await answerWindow()
|
||||
expect(host.added).toEqual(['Ubuntu'])
|
||||
expect(driver.moveProjectsToWslHost).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// B3: in Stage 1 opening a bridge project moves nothing; only the user's own move does.
|
||||
it('T2 in Stage 1: opening a bridge project adds no host and moves nothing', async () => {
|
||||
stage.autoMoves = false
|
||||
try {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
await register()
|
||||
await handlers.get('wslMoveOrchestrator:projectOpened')?.(
|
||||
{ sender: { id: MAIN_WINDOW_ID } },
|
||||
'Ubuntu'
|
||||
)
|
||||
await answerWindow()
|
||||
expect(host.added).toEqual([])
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
expect(driver.moveProjectsToWslHost).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
stage.autoMoves = true
|
||||
}
|
||||
})
|
||||
|
||||
// Setting off is like serve-only: moved projects keep serving, but nothing moves or is added.
|
||||
it('moves nothing and adds no host with the WSL hosts setting off', async () => {
|
||||
setting.on = false
|
||||
@@ -422,35 +325,6 @@ describe('WSL move orchestrator wiring', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('T2: never adds a host for a stopped distro or one whose state cannot be read', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
await register()
|
||||
host.running = { status: 'confirmed', distros: [] }
|
||||
await handlers.get('wslMoveOrchestrator:projectOpened')?.(
|
||||
{ sender: { id: MAIN_WINDOW_ID } },
|
||||
'Ubuntu'
|
||||
)
|
||||
host.running = { status: 'unavailable' }
|
||||
await handlers.get('wslMoveOrchestrator:projectOpened')?.(
|
||||
{ sender: { id: MAIN_WINDOW_ID } },
|
||||
'Debian'
|
||||
)
|
||||
await answerWindow()
|
||||
expect(host.added).toEqual([])
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('T3: a ready host whose distro is not confirmed running is not resumed', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
host.running = { status: 'unavailable' }
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
await answerWindow()
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Review #2: "Use on this computer again" parks the distro, so opening it again moves nothing.
|
||||
it('parks a distro the user brought back', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
|
||||
@@ -147,14 +147,6 @@ turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh
|
||||
expect(result.content).toContain('pre_tool_timeout_ms = 5000 # ms')
|
||||
})
|
||||
|
||||
it('keeps CRLF line endings when editing a Windows-owned config', () => {
|
||||
const source = '[hooks]\r\nturn_end = "~/bin/mine"\r\n'
|
||||
const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh')
|
||||
expect(result.content).toContain('\r\n')
|
||||
expect(result.content).not.toContain('\n[hooks]')
|
||||
expect(result.content).not.toContain('\r\r')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['inline', '[hooks]\npre_tool = ["a.sh"] # gate\n', ['a.sh']],
|
||||
['inline trailing comma', "[hooks]\npre_tool = [ 'a.sh', ]\n", ['a.sh']],
|
||||
|
||||
@@ -74,12 +74,6 @@ describe('kimi managed hooks TOML block', () => {
|
||||
expect(readManagedKimiHookEvents(text, isManaged).size).toBe(0)
|
||||
})
|
||||
|
||||
it('reports no change when removing from a config without the managed block', () => {
|
||||
const { text, changed } = removeManagedKimiHooks('default_model = "x"\n', isManaged)
|
||||
expect(changed).toBe(false)
|
||||
expect(text).toBe('default_model = "x"\n')
|
||||
})
|
||||
|
||||
it('is stable across repeated calls (no stateful global-regex lastIndex drift)', () => {
|
||||
const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged)
|
||||
// Repeated detection must not inherit stateful regex lastIndex.
|
||||
@@ -103,13 +97,6 @@ describe('kimi managed hooks TOML block', () => {
|
||||
const reinstalled = applyManagedKimiHooks(orphaned, COMMAND, isManaged)
|
||||
expect((reinstalled.match(/orca-managed-kimi-hooks \(/g) ?? []).length).toBe(1)
|
||||
})
|
||||
|
||||
it('treats stale managed entries pointing at a moved script path as managed', () => {
|
||||
const staleCommand =
|
||||
"if [ -x '/old/userData/agent-hooks/kimi-hook.sh' ]; then /bin/sh '/old/userData/agent-hooks/kimi-hook.sh'; fi"
|
||||
const stale = applyManagedKimiHooks('', staleCommand, isManaged)
|
||||
expect(readManagedKimiHookEvents(stale, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS))
|
||||
})
|
||||
})
|
||||
|
||||
// #18861: an orphaned start marker used to make every following byte "managed".
|
||||
@@ -322,21 +309,6 @@ describe('orphaned managed block ownership (#18861)', () => {
|
||||
expect(readManagedKimiHookEvents(singleQuoted, isManaged)).toEqual(new Set(['Stop']))
|
||||
})
|
||||
|
||||
it('leaves a hook table that does not invoke the managed script', () => {
|
||||
const orphan = [
|
||||
START_MARKER,
|
||||
'[[hooks]]',
|
||||
'event = "Stop"',
|
||||
'command = "node my-own-hook.mjs"',
|
||||
'timeout = 10',
|
||||
'matcher = "Bash"',
|
||||
''
|
||||
].join('\n')
|
||||
const { text } = removeManagedKimiHooks(orphan, isManaged)
|
||||
expect(text).toContain('command = "node my-own-hook.mjs"')
|
||||
expect(text).toContain('matcher = "Bash"')
|
||||
})
|
||||
|
||||
// Reclaim recognized managed tables even when edits strand them outside the marker block.
|
||||
it('reclaims managed tables stranded below user text', () => {
|
||||
const managedTable = [
|
||||
@@ -350,37 +322,6 @@ describe('orphaned managed block ownership (#18861)', () => {
|
||||
expect(text).toBe('[user.table]\nv = 1\n')
|
||||
})
|
||||
|
||||
it('reports a stranded managed table as live so status cannot claim uninstalled', () => {
|
||||
const stranded = [
|
||||
'[user.table]',
|
||||
'v = 1',
|
||||
'',
|
||||
'[[hooks]]',
|
||||
'event = "PreToolUse"',
|
||||
`command = "${COMMAND.replaceAll('"', '')}"`,
|
||||
'timeout = 10',
|
||||
''
|
||||
].join('\n')
|
||||
expect(readManagedKimiHookEvents(stranded, isManaged)).toEqual(new Set(['PreToolUse']))
|
||||
})
|
||||
|
||||
it('reinstalling over a stranded table does not double-register its event', () => {
|
||||
const stranded = [
|
||||
'[user.table]',
|
||||
'v = 1',
|
||||
'',
|
||||
'[[hooks]]',
|
||||
'event = "PreToolUse"',
|
||||
`command = "${COMMAND.replaceAll('"', '')}"`,
|
||||
'timeout = 10',
|
||||
''
|
||||
].join('\n')
|
||||
const reinstalled = applyManagedKimiHooks(stranded, COMMAND, isManaged)
|
||||
expect((reinstalled.match(/event = "PreToolUse"/g) ?? []).length).toBe(1)
|
||||
expect(reinstalled).toContain('[user.table]')
|
||||
expect(readManagedKimiHookEvents(reinstalled, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS))
|
||||
})
|
||||
|
||||
it('stops an orphaned block at a second start marker', () => {
|
||||
const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged)
|
||||
const duplicated = `${deleteEndMarker(installed)}\n${START_MARKER}\n[user.table]\nv = 1\n`
|
||||
|
||||
@@ -11,10 +11,7 @@ import { installClaudeProfileRouter } from '../../claude-accounts/claude-profile
|
||||
import { resolveStructuredClaudeAccountHomePath } from '../../runtime/structured-agent-account-home'
|
||||
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-record'
|
||||
import {
|
||||
agentModelCatalogFingerprintForRecord,
|
||||
agentModelCatalogSessionAccess
|
||||
} from './agent-model-catalog-fingerprint'
|
||||
import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint'
|
||||
import { AgentModelCatalogListingStoppedError } from './agent-model-catalog-failures'
|
||||
import { createAgentModelCatalogService } from './agent-model-catalog-service'
|
||||
import {
|
||||
@@ -262,69 +259,6 @@ describe('model catalog prewarm', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('lists the new account after a switch and leaves the old account’s catalog alone', async () => {
|
||||
const store = new AgentModelCatalogStore()
|
||||
let selected = '/homes/a'
|
||||
const probe = vi.fn(async (home: AgentSessionAccountHome) =>
|
||||
listing('variable' in home && home.path === '/homes/a' ? 'gpt-a' : 'gpt-b')
|
||||
)
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getConversation: () => undefined,
|
||||
getRecord: () => undefined,
|
||||
drivesRecord: () => true,
|
||||
hasChatRecords: () => true,
|
||||
resolveAccountHome: async () => HOME('CODEX_HOME', selected),
|
||||
probes: { codex: probe }
|
||||
})
|
||||
await service.prewarm()
|
||||
selected = '/homes/b'
|
||||
await service.prewarm()
|
||||
|
||||
expect(probe.mock.calls.map(([home]) => home)).toEqual([
|
||||
HOME('CODEX_HOME', '/homes/a'),
|
||||
HOME('CODEX_HOME', '/homes/b')
|
||||
])
|
||||
const read = await service.read({ agent: 'codex' })
|
||||
expect(read.origin !== 'unknown' && read.models.map((model) => model.id)).toEqual(['gpt-b'])
|
||||
})
|
||||
|
||||
it('respects the failure back-off and skips an agent whose account cannot be resolved', async () => {
|
||||
const store = new AgentModelCatalogStore()
|
||||
const codex = vi.fn(
|
||||
async (_home: AgentSessionAccountHome): Promise<AgentModelCatalogSuccess> => {
|
||||
throw new Error('spawn codex ENOENT')
|
||||
}
|
||||
)
|
||||
const claude = vi.fn(async (_home: AgentSessionAccountHome) => listing('opus'))
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getConversation: () => undefined,
|
||||
getRecord: () => undefined,
|
||||
drivesRecord: () => true,
|
||||
hasChatRecords: () => true,
|
||||
resolveAccountHome: async (agent) => {
|
||||
if (agent === 'claude') {
|
||||
throw new Error('account home unreadable')
|
||||
}
|
||||
return HOME('CODEX_HOME', '/homes/a')
|
||||
},
|
||||
probes: { codex, claude }
|
||||
})
|
||||
spans.started.length = 0
|
||||
await service.prewarm()
|
||||
await service.prewarm()
|
||||
expect(codex).toHaveBeenCalledTimes(1)
|
||||
expect(claude).not.toHaveBeenCalled()
|
||||
expect(spans.started).toEqual([
|
||||
{
|
||||
name: 'agentModelCatalog.discovery',
|
||||
attributes: { agent: 'codex', lister: 'probe' },
|
||||
outcome: 'failed'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('lists only agents used here: a saved catalog or a chat of it', async () => {
|
||||
const store = new AgentModelCatalogStore()
|
||||
const piListed = deferred<AgentModelCatalogSuccess>()
|
||||
@@ -450,30 +384,6 @@ describe('one account, one catalog key', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('a session’s saves through the host and through its adapter’s handle land under its record’s key', async () => {
|
||||
const store = new AgentModelCatalogStore()
|
||||
const managedHome = '/orca/grok-accounts/acct-1/home'
|
||||
const launched = record(managedHome)
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getConversation: () => agentSessionConversationView(launched),
|
||||
getRecord: () => launched,
|
||||
drivesRecord: () => true,
|
||||
resolveAccountHome: async () => HOME('GROK_HOME', managedHome)
|
||||
})
|
||||
service.recordLiveListing('session-1', { models: listing('gpt-live').models })
|
||||
const adapterAccess = agentModelCatalogSessionAccess(
|
||||
store,
|
||||
{ agent: 'grok', accountHomeVariable: 'GROK_HOME' },
|
||||
managedHome
|
||||
)
|
||||
expect(adapterAccess?.fingerprint).toBe(agentModelCatalogFingerprintForRecord(launched))
|
||||
|
||||
// …is what the next new chat reads for the same home: no cold start for this account.
|
||||
const read = await service.read({ agent: 'grok', waitForListing: true })
|
||||
expect(read.origin !== 'unknown' && read.models.map((model) => model.id)).toEqual(['gpt-live'])
|
||||
})
|
||||
|
||||
it('a chat that moved accounts lists under the account it runs on, never its first one', async () => {
|
||||
const store = new AgentModelCatalogStore()
|
||||
const founded = record('/accounts/a')
|
||||
|
||||
@@ -152,61 +152,6 @@ describe('agent model catalog service', () => {
|
||||
expect(store.get(oldFingerprint)!.models[0]!.id).toBe('gpt-old')
|
||||
})
|
||||
|
||||
it('a record-less read serves the selected account entry when it exists', async () => {
|
||||
const store = new AgentModelCatalogStore()
|
||||
store.recordSuccess(
|
||||
selectedHomeFingerprint('/homes/selected'),
|
||||
'grok',
|
||||
listing('gpt-mine'),
|
||||
'discovery'
|
||||
)
|
||||
store.recordSuccess(
|
||||
selectedHomeFingerprint('/homes/other'),
|
||||
'grok',
|
||||
listing('gpt-other'),
|
||||
'discovery'
|
||||
)
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getConversation: () => undefined,
|
||||
getRecord: () => undefined,
|
||||
drivesRecord: () => true,
|
||||
resolveAccountHome: async () => GROK_HOME('/homes/selected')
|
||||
})
|
||||
const result = await service.read({ agent: 'grok', waitForListing: true })
|
||||
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-mine')
|
||||
})
|
||||
|
||||
it('a session record outranks the current selection for its own reads', async () => {
|
||||
const store = new AgentModelCatalogStore()
|
||||
const sessionRecord = record('/homes/session')
|
||||
store.recordSuccess(
|
||||
agentModelCatalogFingerprintForRecord(sessionRecord),
|
||||
'grok',
|
||||
listing('gpt-session'),
|
||||
'discovery'
|
||||
)
|
||||
store.recordSuccess(
|
||||
selectedHomeFingerprint('/homes/selected'),
|
||||
'grok',
|
||||
listing('gpt-selected'),
|
||||
'discovery'
|
||||
)
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getConversation: () => agentSessionConversationView(sessionRecord),
|
||||
getRecord: () => sessionRecord,
|
||||
drivesRecord: () => true,
|
||||
resolveAccountHome: async () => GROK_HOME('/homes/selected')
|
||||
})
|
||||
const result = await service.read({
|
||||
agent: 'grok',
|
||||
sessionId: 'session-1',
|
||||
waitForListing: true
|
||||
})
|
||||
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-session')
|
||||
})
|
||||
|
||||
it('a probe failure is a TTL-bounded fact, never an answer', async () => {
|
||||
const store = new AgentModelCatalogStore()
|
||||
const probe = vi.fn(async () => {
|
||||
@@ -317,45 +262,6 @@ describe('agent model catalog service', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('answers a plain unknown when the listing fails', async () => {
|
||||
const pending = deferredListing()
|
||||
const { service } = coldService(() => pending.promise)
|
||||
const waited = service.read({ agent: 'grok', waitForListing: true })
|
||||
pending.reject(new Error('spawn failed'))
|
||||
expect(await waited).toEqual({ origin: 'unknown' })
|
||||
})
|
||||
|
||||
it('does not wait or report a listing while a failure is inside its TTL', async () => {
|
||||
const probe = vi.fn(async (): Promise<AgentModelCatalogSuccess> => {
|
||||
throw new Error('spawn failed')
|
||||
})
|
||||
const { store, service } = coldService(probe)
|
||||
await store.refresh(selectedHomeFingerprint('/homes/selected'), 'grok', () =>
|
||||
Promise.reject(new Error('spawn failed'))
|
||||
)
|
||||
expect(await service.read({ agent: 'grok', waitForListing: true })).toEqual({
|
||||
origin: 'unknown'
|
||||
})
|
||||
expect(await service.read({ agent: 'grok', waitForListing: true })).toEqual({
|
||||
origin: 'unknown'
|
||||
})
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports no listing where the host has no lister for the account', async () => {
|
||||
const store = new AgentModelCatalogStore()
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getConversation: () => undefined,
|
||||
getRecord: () => undefined,
|
||||
drivesRecord: () => true,
|
||||
resolveAccountHome: async () => GROK_HOME('/homes/selected')
|
||||
})
|
||||
expect(await service.read({ agent: 'grok', waitForListing: true })).toEqual({
|
||||
origin: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('serves an aged entry at once and refreshes it behind the answer', async () => {
|
||||
let now = 0
|
||||
const store = new AgentModelCatalogStore({ now: () => now })
|
||||
@@ -408,30 +314,6 @@ describe('agent model catalog service', () => {
|
||||
return result.origin === 'unknown' ? null : result.models.map((model) => model.isDefault)
|
||||
}
|
||||
|
||||
it('names no default when the workspace config could pick another model', async () => {
|
||||
const { service, workspaceMayOverrideDefaultModel } = serviceWith(true)
|
||||
const result = await service.read({
|
||||
agent: 'grok',
|
||||
waitForListing: true,
|
||||
workspacePath: '/repo/wt'
|
||||
})
|
||||
expect(defaults(result)).toEqual([false])
|
||||
expect(workspaceMayOverrideDefaultModel).toHaveBeenCalledWith({
|
||||
agent: 'grok',
|
||||
workspacePath: '/repo/wt',
|
||||
accountHomePath: '/homes/selected'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the listed default when nothing in the workspace can replace it', async () => {
|
||||
const { service } = serviceWith(false)
|
||||
expect(
|
||||
defaults(
|
||||
await service.read({ agent: 'grok', waitForListing: true, workspacePath: '/repo/wt' })
|
||||
)
|
||||
).toEqual([true])
|
||||
})
|
||||
|
||||
it('names no default for a workspace it could not place on this machine', async () => {
|
||||
const { service, workspaceMayOverrideDefaultModel } = serviceWith(false)
|
||||
expect(
|
||||
@@ -439,12 +321,6 @@ describe('agent model catalog service', () => {
|
||||
).toEqual([false])
|
||||
expect(workspaceMayOverrideDefaultModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a read that names no workspace as it was', async () => {
|
||||
const { service, workspaceMayOverrideDefaultModel } = serviceWith(true)
|
||||
expect(defaults(await service.read({ agent: 'grok', waitForListing: true }))).toEqual([true])
|
||||
expect(workspaceMayOverrideDefaultModel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('an agent whose account is not one directory', () => {
|
||||
|
||||
-156
@@ -1,7 +1,5 @@
|
||||
import { eraseRpcMethods, isStreamingMethod } from '../../runtime/rpc/core'
|
||||
import { STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS } from '../../runtime/rpc/methods/structured-agent-session-options-read'
|
||||
import * as receiving from './structured-agent-session-receiving-account'
|
||||
import { observeReceivingSql } from './structured-agent-session-receiving-sql.test-support'
|
||||
import {
|
||||
type AgentModelCatalogSuccess,
|
||||
AgentModelCatalogStore
|
||||
@@ -10,7 +8,6 @@ import type { AgentModelCatalogProbe } from '../agent-model-catalog/agent-model-
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session/lifecycle/agent-session-mutation-envelope'
|
||||
import { agentSessionAccountHomePath } from '../../../shared/agent-session/lifecycle/agent-session-account-home'
|
||||
import { REST_TEST_CALLER } from './structured-agent-session-rest-test-rig'
|
||||
import { hostTestOperationId } from './structured-agent-session-host-test-data'
|
||||
import {
|
||||
@@ -267,159 +264,6 @@ describe('catalog consumes the constructed host receiving account', () => {
|
||||
}, 10_000)
|
||||
})
|
||||
|
||||
// Real cold adoption, N serialized mutations and bounded cleanup share the existing catalog budget.
|
||||
it.each([10, 100])(
|
||||
'counts inventory-independent picks and explicit receiving catalog work over %i operations',
|
||||
async (n) => {
|
||||
const f = await createReceivingCallerFixture()
|
||||
let service: ReturnType<typeof createAgentModelCatalogService> | undefined
|
||||
try {
|
||||
await f.found()
|
||||
const { started } = await f.coldNative()
|
||||
f.selectB()
|
||||
const c = catalog(f, true)
|
||||
service = c.service
|
||||
f.rig.adapter.acquire.mockClear()
|
||||
const entries = vi.spyOn(receiving, 'structuredAgentSessionReceivingAccount')
|
||||
const external = vi.spyOn(f.rig.host, 'prospectiveAccountHome')
|
||||
const all = vi.spyOn(f.rig.store, 'listRecords').mockImplementation(() => {
|
||||
throw new Error('global history scan')
|
||||
})
|
||||
const pick = async () => {
|
||||
const key = 'permissionMode'
|
||||
const value = 'own'
|
||||
expect(
|
||||
await settled(
|
||||
f.rig.host.setOption(REST_TEST_CALLER, {
|
||||
key,
|
||||
value,
|
||||
envelope: {
|
||||
sessionId: PARENT,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: f.rig.store.requireWritableRecord(PARENT).lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.setOption',
|
||||
sessionId: PARENT,
|
||||
fields: { key, value }
|
||||
})
|
||||
}
|
||||
}),
|
||||
'owned catalog counted mutation'
|
||||
)
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { key, value, options: { permissionMode: 'own' } }
|
||||
})
|
||||
expect(f.rig.store.requireWritableRecord(PARENT).permissionMode).toBe('own')
|
||||
return undefined
|
||||
}
|
||||
const measure = async (count: number, work: () => Promise<void>) => {
|
||||
entries.mockClear()
|
||||
external.mockClear()
|
||||
c.resolve.mockClear()
|
||||
c.demands.mockClear()
|
||||
const observed = observeReceivingSql(f.runtime.getOrchestrationDb())
|
||||
try {
|
||||
await work()
|
||||
expect(entries).toHaveBeenCalledTimes(count)
|
||||
expect(c.resolve).toHaveBeenCalledTimes(count)
|
||||
expect(
|
||||
c.resolve.mock.calls.every(
|
||||
([, cwd, lookup]) => cwd === f.rig.root && lookup === 'snapshot'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(observed.measurements.prepares).toBeLessThanOrEqual(20 * count)
|
||||
expect(observed.measurements.allRows).toBeLessThanOrEqual(8 * count)
|
||||
expect(observed.measurements.plans.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
observed.measurements.plans.filter((detail) =>
|
||||
/\bSCAN (?:run|child|task|parent|messages|dispatch_contexts|agent_session_records)\b/.test(
|
||||
detail
|
||||
)
|
||||
)
|
||||
).toEqual([])
|
||||
expect(c.probe).not.toHaveBeenCalled()
|
||||
expect(f.rig.adapter.acquire).not.toHaveBeenCalled()
|
||||
expect(f.rig.adapter.dispatch).not.toHaveBeenCalled()
|
||||
expect(f.rig.adapter.readOptions).not.toHaveBeenCalled()
|
||||
expect(all).not.toHaveBeenCalled()
|
||||
process.stdout.write(
|
||||
`owned-catalog operations=${count} receiving=${entries.mock.calls.length} snapshotResolutions=${c.resolve.mock.calls.length} sql=${observed.measurements.prepares} rows=${observed.measurements.allRows}\n`
|
||||
)
|
||||
} finally {
|
||||
observed.stop()
|
||||
}
|
||||
}
|
||||
entries.mockClear()
|
||||
c.resolve.mockClear()
|
||||
c.demands.mockClear()
|
||||
const preparation = vi.spyOn(c.service, 'prepareSaved')
|
||||
const reads = vi.spyOn(c.service, 'read')
|
||||
const pickedSql = observeReceivingSql(f.runtime.getOrchestrationDb())
|
||||
try {
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
await pick()
|
||||
}
|
||||
expect(entries).not.toHaveBeenCalled()
|
||||
expect(c.resolve).not.toHaveBeenCalled()
|
||||
expect(c.demands).not.toHaveBeenCalled()
|
||||
expect(preparation).not.toHaveBeenCalled()
|
||||
expect(reads).not.toHaveBeenCalled()
|
||||
expect(pickedSql.measurements.prepares).toBe(0)
|
||||
expect(pickedSql.measurements.allRows).toBe(0)
|
||||
} finally {
|
||||
pickedSql.stop()
|
||||
}
|
||||
await measure(n, async () => {
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const options = await settled(
|
||||
f.rig.host.readOptions(PARENT, { readsWithoutModel: false }),
|
||||
'explicit legacy options'
|
||||
)
|
||||
expect(options.current.model).toBe('claude-sonnet-5')
|
||||
}
|
||||
})
|
||||
expect(preparation).toHaveBeenCalledTimes(n)
|
||||
expect(reads.mock.calls.length).toBeGreaterThan(0)
|
||||
expect(external).not.toHaveBeenCalled()
|
||||
expect(
|
||||
c.resolve.mock.calls.every(([home]) => agentSessionAccountHomePath(home) === HOME_A.path)
|
||||
).toBe(true)
|
||||
entries.mockClear()
|
||||
c.resolve.mockClear()
|
||||
c.demands.mockClear()
|
||||
expect(await c.ids(true)).toEqual(['claude-sonnet-5'])
|
||||
expect(entries).toHaveBeenCalledTimes(1)
|
||||
expect(c.resolve).toHaveBeenCalledTimes(1)
|
||||
expect(c.demands).toHaveBeenCalledTimes(1)
|
||||
const before = await f.rig.host.readOptions(PARENT)
|
||||
f.runtime
|
||||
.getOrchestrationDb()
|
||||
.failWorkerStart(started.dispatch.id, 'agent_readiness', 'never constructed a worker')
|
||||
await measure(1, async () => {
|
||||
const [picked, listed] = await settled(
|
||||
Promise.all([pick(), f.rig.host.readOptions(PARENT, { readsWithoutModel: false })]),
|
||||
'parallel owned/external catalog consumers'
|
||||
)
|
||||
expect(picked).toBeUndefined()
|
||||
expect(listed.current.model).toBe('claude-opus-5-5[1m]')
|
||||
})
|
||||
expect(external).not.toHaveBeenCalled()
|
||||
expect(before.current.model).toBe('claude-sonnet-5')
|
||||
expect(
|
||||
c.resolve.mock.calls.every(([home]) => agentSessionAccountHomePath(home) === HOME_B.path)
|
||||
).toBe(true)
|
||||
} finally {
|
||||
service?.stop()
|
||||
await f.dispose(
|
||||
() => disposeCatalogMutationHost(f.rig),
|
||||
() => rm(f.rig.root, { recursive: true, force: true })
|
||||
)
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
|
||||
// These operations run the real host owner and catalog claim; only external provider IO is scripted.
|
||||
it('keeps newer public intent while discovery waits and reports a failed listing without holding Stop', async () => {
|
||||
const f = await createReceivingCallerFixture()
|
||||
|
||||
@@ -14,7 +14,6 @@ import { hostname, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
acquireOrcadInstanceLock,
|
||||
ORCAD_LOCK_FILE_NAME,
|
||||
@@ -198,61 +197,9 @@ describe('acquireOrcadInstanceLock', () => {
|
||||
writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), JSON.stringify(olderRecord))
|
||||
expect(acquireOrcadInstanceLock(root, liveMismatch(1001)).record.nonce).not.toBe('stale')
|
||||
})
|
||||
|
||||
it('says "an unknown program" when the live process name cannot be read', () => {
|
||||
const root = makeRoot()
|
||||
writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), JSON.stringify(olderRecord))
|
||||
expect(() =>
|
||||
acquireOrcadInstanceLock(root, { ...liveMismatch(1000), processName: () => null })
|
||||
).toThrow(
|
||||
expect.objectContaining({ message: expect.stringContaining('an unknown program') })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the plain refusal for a record that carries a boot id', () => {
|
||||
const root = makeRoot()
|
||||
writeFileSync(
|
||||
join(root, ORCAD_LOCK_FILE_NAME),
|
||||
JSON.stringify({ ...olderRecord, bootId: 'boot-a' })
|
||||
)
|
||||
expect(() => acquireOrcadInstanceLock(root, liveMismatch(1000))).toThrow(
|
||||
expect.objectContaining({
|
||||
code: 'orcad_instance_lock_held',
|
||||
message: expect.stringContaining('already owns the data root')
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('records the incarnation in a lock an older reader still parses', () => {
|
||||
const root = makeRoot()
|
||||
const incarnation = { linuxStartTicks: '1234', bootId: 'boot-a' }
|
||||
const lock = acquireOrcadInstanceLock(root, hooks({ incarnation: () => incarnation }))
|
||||
const written: unknown = JSON.parse(readFileSync(lock.path, 'utf8'))
|
||||
expect(written).toMatchObject({ startedAtMs: 1_000, ...incarnation })
|
||||
// Older schemas strip additive identity fields rather than rejecting the record.
|
||||
const olderReader = z.object({
|
||||
pid: z.number().int().positive(),
|
||||
startedAtMs: z.number().finite().nonnegative().nullable(),
|
||||
identity: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
acquiredAt: z.iso.datetime({ offset: true }),
|
||||
nonce: z.string().min(1),
|
||||
role: z.enum(['orcad', 'desktop']).optional()
|
||||
})
|
||||
expect(olderReader.parse(written)).toEqual({
|
||||
pid: process.pid,
|
||||
startedAtMs: 1_000,
|
||||
identity: 'uid-1000',
|
||||
version: '1.0.0-test',
|
||||
acquiredAt: lock.record.acquiredAt,
|
||||
nonce: lock.record.nonce,
|
||||
role: 'orcad'
|
||||
})
|
||||
lock.release()
|
||||
})
|
||||
|
||||
it('reads an older lock without an incarnation, and salvages a malformed one', () => {
|
||||
const root = makeRoot()
|
||||
writeFileSync(
|
||||
@@ -392,18 +339,6 @@ describe('acquireOrcadInstanceLock', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves the terminal daemon alone: the lock covers only the runtime role', () => {
|
||||
const root = makeRoot()
|
||||
const daemonDir = join(root, 'daemon')
|
||||
mkdirSync(daemonDir, { recursive: true })
|
||||
writeFileSync(join(daemonDir, 'daemon-v36.pid'), JSON.stringify({ pid: 99, startedAtMs: 1 }))
|
||||
const lock = acquireOrcadInstanceLock(root, hooks())
|
||||
lock.release()
|
||||
expect(JSON.parse(readFileSync(join(daemonDir, 'daemon-v36.pid'), 'utf8')).pid).toBe(99)
|
||||
const next = acquireOrcadInstanceLock(root, hooks())
|
||||
expect(next.record.pid).toBe(process.pid)
|
||||
})
|
||||
|
||||
it('restricts a Windows data root by ACL, and refuses when the ACL cannot be applied', () => {
|
||||
const root = makeRoot()
|
||||
const restricted: string[] = []
|
||||
|
||||
@@ -121,10 +121,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('registers nothing outside an Orca pane', () => {
|
||||
expect(createHarness({ paneKey: '' }).handlers).toEqual({})
|
||||
})
|
||||
|
||||
it('stops the spinner when the agent settles', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
@@ -176,30 +172,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('keeps spinning through threshold compaction inside an active run', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('auto_compaction_start', { reason: 'threshold' })
|
||||
await harness.callHook('auto_compaction_end', { reason: 'threshold' })
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
vi.advanceTimersByTime(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('does not let an idle compaction adopt an already-running agent spinner', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('auto_compaction_start', { reason: 'idle' })
|
||||
await harness.callHook('auto_compaction_end', { reason: 'idle' })
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
vi.advanceTimersByTime(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('does not let a stale idle-compaction completion stop a newer run', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
@@ -212,21 +184,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('transfers an idle-compaction spinner to a new run without publishing idle', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('auto_compaction_start', { reason: 'idle' })
|
||||
vi.advanceTimersByTime(80)
|
||||
const titleCountBeforeTransfer = harness.titles.length
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
|
||||
const transferTitles = harness.titles.slice(titleCountBeforeTransfer)
|
||||
expect(transferTitles).not.toContain(IDLE_TITLE)
|
||||
expect(transferTitles).toHaveLength(1)
|
||||
expect(transferTitles[0]).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it.each([{ kind: 'omp' as const }, { processTitle: 'omp' }])(
|
||||
'stops final OMP completion without waiting for isIdle: %j',
|
||||
async (options) => {
|
||||
@@ -297,17 +254,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('returns an idle pane to its plain title when the dialog closes', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
await harness.callHook('ui_prompt_end')
|
||||
expect(harness.lastTitle()).toBe(IDLE_TITLE)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('only the outermost of nested dialogs moves the title', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
@@ -321,15 +267,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('ignores an unmatched dialog close', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
const titleCount = harness.titles.length
|
||||
await harness.callHook('ui_prompt_end')
|
||||
expect(harness.titles.length).toBe(titleCount)
|
||||
})
|
||||
|
||||
it.each(['agent_settled', 'session_shutdown'])(
|
||||
'keeps the marker when %s lands under an open dialog',
|
||||
async (name) => {
|
||||
@@ -345,49 +282,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the marker across an idle compaction that finishes under a dialog', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
await harness.callHook('auto_compaction_start', { reason: 'idle' })
|
||||
await harness.callHook('auto_compaction_end')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
})
|
||||
|
||||
it('recovers the spinner on a new turn when a dialog close was lost', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('leaves the marker to OMP approval events instead of painting it', () => {
|
||||
expect(createHarness({ kind: 'omp' }).handlers.ui_prompt_start).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still caps idle maintenance while a dialog holds the title', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('auto_compaction_start', { reason: 'idle' })
|
||||
await harness.callHook('ui_prompt_start')
|
||||
vi.advanceTimersByTime(301_000)
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
})
|
||||
|
||||
it('survives a dialog event that carries no ui context', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await expect(harness.handlers.ui_prompt_start?.({}, undefined)).resolves.toBeUndefined()
|
||||
await expect(harness.handlers.ui_prompt_end?.({}, undefined)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps spinning when the dialog event could not paint the marker', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
@@ -398,63 +292,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('marks a nested dialog when the outer one could not paint', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.handlers.ui_prompt_start?.({}, undefined)
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
})
|
||||
|
||||
it('clears the marker through the opening ctx when the close carries none', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
await harness.handlers.ui_prompt_end?.({}, undefined)
|
||||
expect(harness.lastTitle()).toBe(IDLE_TITLE)
|
||||
})
|
||||
|
||||
it('does not reject when the dialog ctx can no longer paint', async () => {
|
||||
const harness = createHarness()
|
||||
const throwing = {
|
||||
ui: {
|
||||
setTitle: () => {
|
||||
throw new Error('extension runner is no longer active')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await expect(harness.handlers.ui_prompt_start?.({}, throwing)).resolves.toBeUndefined()
|
||||
await harness.callHook('agent_start')
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('does not reject when the captured ctx dies before the dialog closes', async () => {
|
||||
const harness = createHarness()
|
||||
let live = true
|
||||
const dying = {
|
||||
ui: {
|
||||
setTitle: (title: string) => {
|
||||
if (!live) {
|
||||
throw new Error('extension runner is no longer active')
|
||||
}
|
||||
harness.titles.push(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await harness.handlers.ui_prompt_start?.({}, dying)
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
live = false
|
||||
await expect(harness.handlers.ui_prompt_end?.({}, undefined)).resolves.toBeUndefined()
|
||||
await harness.callHook('agent_start')
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('does not strand the marker when the closing ctx throws on ui access', async () => {
|
||||
const harness = createHarness()
|
||||
// Pi ctx.ui throws after session replacement invalidates its runner.
|
||||
@@ -475,20 +312,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('does not reject when the opening ctx throws on ui access', async () => {
|
||||
const harness = createHarness()
|
||||
const stale = {
|
||||
get ui(): never {
|
||||
throw new Error('This extension ctx is stale')
|
||||
}
|
||||
}
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await expect(harness.handlers.ui_prompt_start?.({}, stale as never)).resolves.toBeUndefined()
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('re-asserts the marker when pi repaints the title under a dialog', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
@@ -518,53 +341,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('releases the marker when a session replacement drops the dialog', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.callHook('ui_prompt_start')
|
||||
expect(harness.lastTitle()).toBe(PROMPT_TITLE)
|
||||
|
||||
// Session replacement hides dialogs without emitting close.
|
||||
await harness.callHook('session_start', { reason: 'switch' })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await harness.callHook('agent_start')
|
||||
await vi.advanceTimersByTimeAsync(80)
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('survives a deleted cwd instead of crashing the pi process', async () => {
|
||||
const harness = createHarness({
|
||||
cwdImpl: () => {
|
||||
throw new Error('ENOENT: uv_cwd')
|
||||
}
|
||||
})
|
||||
|
||||
// Timer callback errors reach Pi's uncaughtException handler and exit the agent.
|
||||
await expect(harness.callHook('agent_start')).resolves.toBeUndefined()
|
||||
await expect(harness.callHook('ui_prompt_start')).resolves.toBeUndefined()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await expect(harness.callHook('ui_prompt_end')).resolves.toBeUndefined()
|
||||
await expect(harness.callHook('agent_settled')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('survives a session name that throws on a stale runtime', async () => {
|
||||
let live = true
|
||||
const harness = createHarness({
|
||||
sessionNameImpl: () => {
|
||||
if (!live) {
|
||||
throw new Error('This extension API is stale')
|
||||
}
|
||||
return SESSION
|
||||
}
|
||||
})
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('ui_prompt_start')
|
||||
live = false
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await expect(harness.callHook('ui_prompt_end')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves the needs-input marker to the process that owns the pane', async () => {
|
||||
// Child agents inherit pane identity; only its owning process may assert needs-input.
|
||||
const harness = createHarness({ env: { ORCA_PI_TITLE_MARKER_OWNED: '111' } })
|
||||
@@ -576,13 +352,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
expect(harness.lastTitle()).toMatch(BRAILLE_RE)
|
||||
})
|
||||
|
||||
it('leaves an OMP runtime to its own approval events', () => {
|
||||
const harness = createHarness({ processTitle: 'omp' })
|
||||
|
||||
expect(harness.handlers.ui_prompt_start).toBeDefined()
|
||||
expect(() => harness.handlers.ui_prompt_start?.({}, undefined)).not.toThrow()
|
||||
})
|
||||
|
||||
it.each(['getter', 'title'] as const)(
|
||||
'retires a stale %s during animation without throwing or rescheduling',
|
||||
async (failure) => {
|
||||
@@ -612,39 +381,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['getter', 'title'] as const)('contains stale %s during shutdown', async (failure) => {
|
||||
let stale = false
|
||||
const harness = createHarness({
|
||||
sessionNameImpl: () => {
|
||||
if (stale && failure === 'getter') {
|
||||
throw new Error('expired session')
|
||||
}
|
||||
return SESSION
|
||||
},
|
||||
setTitle: () => {
|
||||
if (stale && failure === 'title') {
|
||||
throw new Error('expired UI')
|
||||
}
|
||||
},
|
||||
isIdle: () => false
|
||||
})
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('agent_end')
|
||||
stale = true
|
||||
await expect(harness.callHook('session_shutdown')).resolves.toBeUndefined()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not schedule a timer when the first frame fails', async () => {
|
||||
const harness = createHarness({
|
||||
sessionNameImpl: () => {
|
||||
throw new Error('expired')
|
||||
}
|
||||
})
|
||||
await harness.callHook('agent_start')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('retires animation when an idle recheck loses its session', async () => {
|
||||
const harness = createHarness({
|
||||
isIdle: () => {
|
||||
@@ -657,17 +393,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('clears animation and pending idle checks on session replacement', async () => {
|
||||
const harness = createHarness({ isIdle: () => false })
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('agent_end')
|
||||
await harness.callHook('session_shutdown')
|
||||
await harness.callHook('session_start')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await harness.callHook('agent_start')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('reload replaces only its pane owner and ignores late old-generation events', async () => {
|
||||
const globals = {}
|
||||
const old = createHarness({ globals, isIdle: () => false })
|
||||
@@ -743,13 +468,6 @@ describe('getPiTitlebarExtensionSource', () => {
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('paints the rest title at session_start so a branded Pi reads as ready before its first turn', async () => {
|
||||
const harness = createHarness()
|
||||
await harness.callHook('session_start', { reason: 'startup' })
|
||||
expect(harness.lastTitle()).toBe(IDLE_TITLE)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('drops a leftover needs-input marker when session_start replaces the session', async () => {
|
||||
const harness = createHarness()
|
||||
await harness.callHook('ui_prompt_start')
|
||||
|
||||
-140
@@ -5,11 +5,9 @@ import {
|
||||
assertWorktreeCleanForRemoval,
|
||||
closeLocalWatcherForWorktreePathMock,
|
||||
computeWorktreePathMock,
|
||||
deleteWorktreeHistoryDirMock,
|
||||
ensurePathWithinWorkspaceMock,
|
||||
getEffectiveHooks,
|
||||
gitRunner,
|
||||
invalidateAuthorizedRootsCacheMock,
|
||||
join,
|
||||
listWorktrees,
|
||||
listWorktreesStrict,
|
||||
@@ -286,60 +284,6 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('formats preflight subprocess failures and skips PTY teardown', async () => {
|
||||
const runtime = createWorktreeRemovalRuntime()
|
||||
const killSpy = vi.fn().mockReturnValue(true)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: (id) => killSpy(id),
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
syncSinglePty(runtime, 'pty-1')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
vi.mocked(assertWorktreeCleanForRemoval).mockRejectedValue(
|
||||
Object.assign(new Error('status failed'), {
|
||||
stderr: 'fatal: unable to read current working directory\n'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).rejects.toThrow(
|
||||
`Failed to delete worktree at ${TEST_WORKTREE_PATH}. fatal: unable to read current working directory`
|
||||
)
|
||||
|
||||
expect(killSpy).not.toHaveBeenCalled()
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through to orphan cleanup when preflight reports missing/non-repo worktree', async () => {
|
||||
const runtime = createWorktreeRemovalRuntime()
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
vi.mocked(assertWorktreeCleanForRemoval).mockRejectedValue(
|
||||
Object.assign(new Error('status failed'), {
|
||||
stderr: 'fatal: not a git repository (or any of the parent directories): .git\n'
|
||||
})
|
||||
)
|
||||
vi.mocked(removeWorktree).mockRejectedValue(
|
||||
Object.assign(new Error('git worktree remove failed'), {
|
||||
stderr: `fatal: '${TEST_WORKTREE_PATH}' is not a working tree`
|
||||
})
|
||||
)
|
||||
const gitSpy = vi
|
||||
.spyOn(gitRunner, 'gitExecFileAsync')
|
||||
.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
onTestFinished(() => gitSpy.mockRestore())
|
||||
|
||||
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).resolves.toEqual({})
|
||||
expect(removeWorktree).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
TEST_WORKTREE_PATH,
|
||||
false,
|
||||
expect.objectContaining({
|
||||
knownRemovedWorktree: expect.objectContaining({ path: TEST_WORKTREE_PATH })
|
||||
})
|
||||
)
|
||||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(TEST_WORKTREE_ID)
|
||||
})
|
||||
|
||||
it('retains metadata when an unproven orphan directory survives cleanup', async () => {
|
||||
const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-orphan-retention-'))
|
||||
const repoPath = join(parentDir, 'repo')
|
||||
@@ -523,35 +467,6 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('runs archive hooks for CLI worktree removal when hooks are explicitly enabled', async () => {
|
||||
const runtime = createWorktreeRemovalRuntime()
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
archive: 'pnpm worktree:archive'
|
||||
}
|
||||
})
|
||||
vi.mocked(runHook).mockResolvedValue({ success: true, output: '' })
|
||||
vi.mocked(removeWorktree).mockResolvedValue({})
|
||||
|
||||
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: false, runHooks: true })
|
||||
|
||||
expect(runHook).toHaveBeenCalledWith(
|
||||
'archive',
|
||||
TEST_WORKTREE_PATH,
|
||||
expect.objectContaining({ id: TEST_REPO_ID, path: TEST_REPO_PATH }),
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(removeWorktree).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
TEST_WORKTREE_PATH,
|
||||
false,
|
||||
expect.objectContaining({
|
||||
knownRemovedWorktree: expect.objectContaining({ path: TEST_WORKTREE_PATH })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('clears optimistic reconcile tokens when a CLI worktree removal succeeds', async () => {
|
||||
const runtime = createWorktreeRemovalRuntime()
|
||||
const worktreeBaseStatus = vi.fn()
|
||||
@@ -613,25 +528,6 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('emits current when the fetched base still matches created HEAD', async () => {
|
||||
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
|
||||
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({ postFetchSha: 'created-base-sha' })
|
||||
try {
|
||||
await reconcileWithToken(runtime, token)
|
||||
|
||||
expect(worktreeBaseStatus).toHaveBeenCalledWith({
|
||||
repoId: TEST_REPO_ID,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
base: 'origin/main',
|
||||
remote: 'origin',
|
||||
status: 'current'
|
||||
})
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits base_changed without mutation when the fetched base rewrote history', async () => {
|
||||
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
|
||||
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
@@ -697,42 +593,6 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidates the filesystem-auth cache after CLI worktree creation', async () => {
|
||||
// A stale filesystem-auth cache denied branch comparison for CLI-created paths.
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-worktree')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/cli-worktree')
|
||||
vi.mocked(listWorktrees).mockResolvedValueOnce([
|
||||
{
|
||||
path: '/tmp/workspaces/cli-worktree',
|
||||
head: 'abc',
|
||||
branch: 'cli-worktree',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'cli-worktree'
|
||||
})
|
||||
|
||||
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves create-time metadata on later runtime listings when Windows path formatting differs', async () => {
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
|
||||
@@ -194,21 +194,6 @@ describe('structured mailbox pointer delivery', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('claims only mailboxes whose assignee is a structured worker', () => {
|
||||
const { delivery } = harness({})
|
||||
expect(delivery.deliverForHandle('dispatch:d1')).toBe(true)
|
||||
expect(delivery.deliverForHandle('run:run_1')).toBe(false)
|
||||
})
|
||||
|
||||
it('sends the pointer as a turn and consumes mail on an accepted dispatch', async () => {
|
||||
const { delivery, markAsDelivered, send } = harness({})
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
expect(send.mock.calls[0]![0].operationId).toMatch(/^\d{13}-[0-9a-f]{32}$/)
|
||||
expect(markAsDelivered).toHaveBeenCalledWith(['m1'], 'dispatch:d1')
|
||||
})
|
||||
|
||||
it('nudges through the worker`s own handle for direct peer mail outside a dispatch', async () => {
|
||||
const { delivery, send, markAsDelivered } = harness({
|
||||
mailbox: IDENTITY.handle,
|
||||
@@ -239,22 +224,6 @@ describe('structured mailbox pointer delivery', () => {
|
||||
expect(markAsDelivered).toHaveBeenCalledWith(['m1'], 'dispatch:d1')
|
||||
})
|
||||
|
||||
it('retains mail when the dispatch settles unknown', async () => {
|
||||
const { delivery, markAsDelivered } = harness({
|
||||
dispatchState: 'unknown'
|
||||
})
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
expect(markAsDelivered).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains mail when the session is not attached', async () => {
|
||||
const { delivery, send } = harness({ attached: false })
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redrives a detached session when the journal replays on re-attach', async () => {
|
||||
// A detached session needs a parked journal-edge retry because workers are told not to poll.
|
||||
const { delivery, send, setAttached, markAsDelivered } = harness({ attached: false })
|
||||
@@ -549,22 +518,6 @@ describe('structured mailbox pointer delivery', () => {
|
||||
expect(markAsDelivered).toHaveBeenCalledWith(['m1'], 'dispatch:d1')
|
||||
expect(stored.has('dispatch:d1')).toBe(false)
|
||||
})
|
||||
|
||||
it('reuses one operation id for the same batch and re-mints when it grows', async () => {
|
||||
const { delivery, send, stored } = harness({
|
||||
dispatchState: 'unknown'
|
||||
})
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
const first = send.mock.calls[0]![0].operationId
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
expect(send.mock.calls[1]![0].operationId).toBe(first)
|
||||
stored.clear()
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
expect(send.mock.calls[2]![0].operationId).not.toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('forgetting one settled worker', () => {
|
||||
@@ -768,16 +721,6 @@ describe('a mailbox a /clear moves while its nudge is in flight', () => {
|
||||
expect(pointed.has('m1')).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an unchanged rejected nudge parked for the next edge', async () => {
|
||||
const { delivery, send, settle } = clearingHarness()
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
settle('rejected')
|
||||
await flush()
|
||||
await flush()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['gone', 'live-other-kind', 'metadata-read-fails'])(
|
||||
|
||||
-22
@@ -348,28 +348,6 @@ describe('orchestration worker-start prompt contract', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a worker question answerable after turn observation times out', async () => {
|
||||
vi.useFakeTimers()
|
||||
const harness = await createPromptContractHarness('swallowed')
|
||||
let questionId = ''
|
||||
vi.spyOn(harness.runtime, 'observeTerminalAgentPrompt').mockImplementation(
|
||||
async (_handle, prompt) => {
|
||||
const dispatch = harness.db.findActiveDispatchForAssignee(harness.handle)!
|
||||
questionId = harness.db.createQuestion({
|
||||
runId: dispatch.run_id!,
|
||||
dispatchId: dispatch.id,
|
||||
askerHandle: harness.handle,
|
||||
question: 'Which target should I use?'
|
||||
}).question.message_id
|
||||
return prompt
|
||||
}
|
||||
)
|
||||
const pending = harness.dispatcher.dispatch(harness.request)
|
||||
await vi.runAllTimersAsync()
|
||||
expect(await pending).toMatchObject({ ok: true, result: { state: 'outcome_unknown' } })
|
||||
expect(harness.db.getQuestion(questionId)?.status).toBe('pending')
|
||||
})
|
||||
|
||||
it('reports a swallowed Enter as start_unknown while keeping the worker reportable', async () => {
|
||||
vi.useFakeTimers()
|
||||
const harness = await createPromptContractHarness('swallowed')
|
||||
|
||||
@@ -495,27 +495,6 @@ describe.skipIf(runningBaselineOracle)('real runtime session tabs census boundar
|
||||
])
|
||||
})
|
||||
|
||||
it('uses one change sequence across subscribers without duplicate fanout', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const created = runtimeSnapshot('wt-census-race', 1)
|
||||
const first: number[] = []
|
||||
const second: number[] = []
|
||||
const unsubscribeFirst = runtime.onMobileSessionTabsChanged((_snapshot, sequence) =>
|
||||
first.push(sequence)
|
||||
)
|
||||
const unsubscribeSecond = runtime.onMobileSessionTabsChanged((_snapshot, sequence) =>
|
||||
second.push(sequence)
|
||||
)
|
||||
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [created] })
|
||||
;(runtime as unknown as RuntimeInventoryInternals).mobileSessionTabsNotifyCoalescer.flushAll()
|
||||
|
||||
expect(first).toEqual([expect.any(Number)])
|
||||
expect(second).toEqual(first)
|
||||
unsubscribeFirst()
|
||||
unsubscribeSecond()
|
||||
})
|
||||
|
||||
it('aborts the census and removes the real runtime listener on disconnect', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] })
|
||||
@@ -543,101 +522,6 @@ describe.skipIf(runningBaselineOracle)('real runtime session tabs census boundar
|
||||
})
|
||||
|
||||
describe('session tabs inventory census boundary', () => {
|
||||
it('subsumes a change that precedes the final census snapshot', async () => {
|
||||
const harness = createHarness()
|
||||
const prior = snapshot(1, [terminalTab('prior')], { publicationEpoch: 'epoch-prior' })
|
||||
const current = snapshot(1, [terminalTab('current')])
|
||||
const pending = subscribeSessionTabsInventory(harness.context, harness.emit)
|
||||
|
||||
await Promise.resolve()
|
||||
harness.publish(prior)
|
||||
harness.census.resolve({ snapshots: [current], authoritative: true, changeSequence: 1 })
|
||||
await pending
|
||||
|
||||
expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([
|
||||
{ type: 'snapshots', snapshots: [current], authoritative: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('subsumes a creation captured at the census boundary exactly once', async () => {
|
||||
const harness = createHarness()
|
||||
const created = snapshot(2, [terminalTab('created')])
|
||||
const pending = subscribeSessionTabsInventory(harness.context, harness.emit)
|
||||
|
||||
await Promise.resolve()
|
||||
harness.publish(created)
|
||||
harness.census.resolve({ snapshots: [created], authoritative: true, changeSequence: 1 })
|
||||
await pending
|
||||
|
||||
expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([
|
||||
{ type: 'snapshots', snapshots: [created], authoritative: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('delivers a creation after the census boundary as the next ordered update', async () => {
|
||||
const harness = createHarness()
|
||||
const created = snapshot(2, [terminalTab('created')])
|
||||
const pending = subscribeSessionTabsInventory(harness.context, harness.emit)
|
||||
|
||||
await Promise.resolve()
|
||||
harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 })
|
||||
queueMicrotask(() => harness.publish(created))
|
||||
await pending
|
||||
|
||||
expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([
|
||||
{ type: 'snapshots', snapshots: [], authoritative: true },
|
||||
{ type: 'updated', ...created }
|
||||
])
|
||||
})
|
||||
|
||||
it('delivers a removal after the census boundary as the next ordered update', async () => {
|
||||
const harness = createHarness()
|
||||
const existing = snapshot(1, [terminalTab('existing')])
|
||||
const removed = snapshot(0, [], { publicationEpoch: 'removed:epoch', removed: true })
|
||||
const pending = subscribeSessionTabsInventory(harness.context, harness.emit)
|
||||
|
||||
await Promise.resolve()
|
||||
harness.census.resolve({ snapshots: [existing], authoritative: true, changeSequence: 0 })
|
||||
queueMicrotask(() => harness.publish(removed))
|
||||
await pending
|
||||
|
||||
expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([
|
||||
{ type: 'snapshots', snapshots: [existing], authoritative: true },
|
||||
{ type: 'updated', ...removed }
|
||||
])
|
||||
})
|
||||
|
||||
it('delivers ordinary changes after initialization without duplication', async () => {
|
||||
const harness = createHarness()
|
||||
const created = snapshot(2, [terminalTab('created')])
|
||||
const pending = subscribeSessionTabsInventory(harness.context, harness.emit)
|
||||
|
||||
await Promise.resolve()
|
||||
harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 })
|
||||
await pending
|
||||
harness.publish(created)
|
||||
|
||||
expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([
|
||||
{ type: 'snapshots', snapshots: [], authoritative: true },
|
||||
{ type: 'updated', ...created }
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a delayed callback already covered by the census watermark', async () => {
|
||||
const harness = createHarness()
|
||||
const included = snapshot(2, [terminalTab('included')])
|
||||
const pending = subscribeSessionTabsInventory(harness.context, harness.emit)
|
||||
|
||||
await Promise.resolve()
|
||||
harness.census.resolve({ snapshots: [included], authoritative: true, changeSequence: 1 })
|
||||
await pending
|
||||
harness.deliver(included, 1)
|
||||
|
||||
expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([
|
||||
{ type: 'snapshots', snapshots: [included], authoritative: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates a post-watermark notification already projected by the census', async () => {
|
||||
const harness = createHarness()
|
||||
const included = snapshot(2, [terminalTab('included')])
|
||||
@@ -675,30 +559,6 @@ describe('session tabs inventory census boundary', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('replays buffered follow intent before the later ordinary snapshot', async () => {
|
||||
const harness = createHarness()
|
||||
const followed = {
|
||||
...snapshot(1, [terminalTab('followed')]),
|
||||
navigationIntent: 'follow' as const
|
||||
}
|
||||
const latest = snapshot(2, [terminalTab('latest')])
|
||||
const pending = subscribeSessionTabsInventory(harness.context, harness.emit)
|
||||
|
||||
await Promise.resolve()
|
||||
harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 })
|
||||
queueMicrotask(() => {
|
||||
harness.publish(followed)
|
||||
harness.publish(latest)
|
||||
})
|
||||
await pending
|
||||
|
||||
expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([
|
||||
{ type: 'snapshots', snapshots: [], authoritative: true },
|
||||
{ type: 'updated', ...followed },
|
||||
{ type: 'updated', ...latest }
|
||||
])
|
||||
})
|
||||
|
||||
it('bounds non-structural initialization churn to the latest projected state', async () => {
|
||||
const harness = createHarness()
|
||||
const pending = subscribeSessionTabsInventory(harness.context, harness.emit)
|
||||
@@ -718,32 +578,6 @@ describe('session tabs inventory census boundary', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('bounds alternating selection churn to the latest projected state', async () => {
|
||||
const harness = createHarness()
|
||||
const pending = subscribeSessionTabsInventory(harness.context, harness.emit)
|
||||
|
||||
await Promise.resolve()
|
||||
harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 })
|
||||
queueMicrotask(() => {
|
||||
for (let version = 1; version <= 1_000; version += 1) {
|
||||
const selected = version % 2 === 0
|
||||
harness.publish({
|
||||
...snapshot(version, [{ ...terminalTab('stable'), isActive: selected }]),
|
||||
activeTabId: selected ? 'stable' : null,
|
||||
activeTabType: selected ? 'terminal' : null
|
||||
})
|
||||
}
|
||||
})
|
||||
await pending
|
||||
|
||||
expect(harness.emit).toHaveBeenCalledTimes(2)
|
||||
expect(harness.emit.mock.calls[1]?.[0]).toMatchObject({
|
||||
type: 'updated',
|
||||
snapshotVersion: 1_000,
|
||||
activeTabId: 'stable'
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds repeated follow intent to the latest caller request', async () => {
|
||||
const harness = createHarness()
|
||||
const pending = subscribeSessionTabsInventory(harness.context, harness.emit)
|
||||
@@ -883,18 +717,4 @@ describe('session tabs inventory census boundary', () => {
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(emit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops buffered work and removes the listener when disposed during census', async () => {
|
||||
const harness = createHarness()
|
||||
const pending = subscribeSessionTabsInventory(harness.context, harness.emit)
|
||||
|
||||
await Promise.resolve()
|
||||
harness.cleanup()
|
||||
harness.publish(snapshot(2, [terminalTab('late')]))
|
||||
harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 })
|
||||
await pending
|
||||
|
||||
expect(harness.emit).not.toHaveBeenCalled()
|
||||
expect(harness.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -699,26 +699,6 @@ describe('finishing an interrupted worktree removal after a restart', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the user’s non-forced choice after Git deleted part of the checkout: the row returns as before', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
await writeFile(join(worktreePath, 'kept.txt'), 'kept\n')
|
||||
await git(['add', 'kept.txt'], worktreePath)
|
||||
await git(['commit', '-qm', 'kept'], worktreePath)
|
||||
// Git stopped partway: some tracked files are gone, `.git` is still there.
|
||||
await unlink(join(worktreePath, 'seed.txt'))
|
||||
|
||||
const { outcome, purged, records } = await finishAfterRestart({ force: false })
|
||||
|
||||
expect(outcome).toMatchObject({ status: 'failed' })
|
||||
expect(await readFile(join(worktreePath, 'kept.txt'), 'utf8')).toBe('kept\n')
|
||||
expect(existsSync(join(worktreePath, '.git'))).toBe(true)
|
||||
expect(await isRegistered(worktreePath)).toBe(true)
|
||||
expect(await git(['branch', '--list', 'feature'])).not.toBe('')
|
||||
expect(purged).toEqual([])
|
||||
expect(records).toMatchObject([{ failure: { message: expect.any(String) } }])
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath, removalError: expect.any(String) }])
|
||||
})
|
||||
|
||||
it('deletes nothing in a folder put at the path after Git unregistered the checkout', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
await git(['worktree', 'remove', worktreePath])
|
||||
@@ -853,21 +833,6 @@ describe('finishing an interrupted worktree removal after a restart', () => {
|
||||
expect(purged).toEqual([`repo-1::${worktreePath}`])
|
||||
})
|
||||
|
||||
it('returns the row live when the finish fails, instead of retrying unseen', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// Another Git client locked it while Orca was not running.
|
||||
await git(['worktree', 'lock', worktreePath])
|
||||
|
||||
const { outcome, purged, records } = await finishAfterRestart({ force: true })
|
||||
|
||||
expect(outcome).toMatchObject({ status: 'failed' })
|
||||
expect(existsSync(worktreePath)).toBe(true)
|
||||
expect(await isRegistered(worktreePath)).toBe(true)
|
||||
expect(purged).toEqual([])
|
||||
expect(records).toMatchObject([{ failure: { message: expect.any(String) } }])
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath, removalError: expect.any(String) }])
|
||||
})
|
||||
|
||||
it('drops a record whose repo Orca no longer has', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ import type { RuntimeWorktreeScanCache, RuntimeWorktreeScanInFlight } from '../o
|
||||
import type { RuntimeWorktreeScanResult } from '../repo-worktree-resolution-scan'
|
||||
import type { ResolvedWorktree } from '../runtime-worktree-path-identity'
|
||||
import type { ResolvedWorktreeScanJoin } from '../workspaces/resolved-worktree-scan-join'
|
||||
import { getRuntimeFolderWorkspaceInstanceId } from '../runtime-folder-workspace'
|
||||
|
||||
type ScopeFixture = {
|
||||
store: RuntimeStore
|
||||
@@ -481,151 +480,3 @@ it('shares one failed SSH stored-row refresh at N and 10N callers without cachin
|
||||
expect.soft(refresh, `${callers} callers then a new request`).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('catalog-scale corpus measures held RPC qualification without optional work or metadata writes', async () => {
|
||||
for (const repoCount of [20, 200]) {
|
||||
const h = rig()
|
||||
h.repos.splice(0)
|
||||
for (const key of Object.keys(h.metadata)) {
|
||||
delete h.metadata[key]
|
||||
}
|
||||
let gitRows = 0
|
||||
for (let index = 0; index < repoCount; index++) {
|
||||
const folder = index % 10 === 0
|
||||
const repo: Repo = {
|
||||
id: `r${index + 1}`,
|
||||
path: `/private-fixture/w${index + 1}`,
|
||||
displayName: `Repository ${index}`,
|
||||
kind: folder ? 'folder' : 'git',
|
||||
badgeColor: '',
|
||||
addedAt: 0,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
h.repos.push(repo)
|
||||
const worktrees = Array.from({ length: 4 }, (_, row) => ({
|
||||
path: row === 0 ? repo.path : `${repo.path}/checkouts/feature-${row}`,
|
||||
head: `commit-${index}-${row}`,
|
||||
branch: `refs/heads/feature-${row}`,
|
||||
isMainWorktree: row === 0,
|
||||
isBare: false
|
||||
}))
|
||||
worktrees.forEach((row, number) => {
|
||||
const id = folder
|
||||
? number === 0
|
||||
? `${repo.id}::${repo.path}`
|
||||
: getRuntimeFolderWorkspaceInstanceId(repo, `folder-${number}`)
|
||||
: `${repo.id}::${row.path}`
|
||||
h.metadata[id] = {
|
||||
instanceId: `instance-${index}-${number}`,
|
||||
hostId: 'local',
|
||||
displayName: index === 1 && number === 0 ? 'W2' : `Workspace ${index}-${number}`
|
||||
}
|
||||
})
|
||||
if (!folder) {
|
||||
gitRows += worktrees.length
|
||||
h.runtime.worktreeScanCache.set(`${repo.id}\0local`, {
|
||||
generation: 0,
|
||||
runtimeKey: '',
|
||||
result: { ok: true, worktrees },
|
||||
listing: { ok: true, worktrees },
|
||||
expiresAt: Date.now() + 60_000,
|
||||
adminFingerprint: null,
|
||||
scannedAt: Date.now()
|
||||
})
|
||||
}
|
||||
}
|
||||
const models = Array.from({ length: repoCount * 4 }, (_, index) => ({
|
||||
id: `provider/model-${index}`,
|
||||
label: `Provider model ${index}`,
|
||||
isDefault: index === 0,
|
||||
efforts: [{ value: 'high', label: 'High' }]
|
||||
}))
|
||||
h.catalogStore.recordSuccess(
|
||||
h.fingerprint,
|
||||
'opencode',
|
||||
{ origin: 'probe', models },
|
||||
'discovery'
|
||||
)
|
||||
await h.catalog.demand({
|
||||
agent: 'opencode',
|
||||
workspace: {
|
||||
selector: 'name:W2',
|
||||
identity: () => h.runtime.structuredAgentSessionCatalogWorkspaceIdentity('name:W2'),
|
||||
resolve: async () => ({
|
||||
identity: h.runtime.structuredAgentSessionCatalogWorkspaceIdentity('name:W2'),
|
||||
path: '/private-fixture/w2'
|
||||
})
|
||||
}
|
||||
})
|
||||
let repoVisits = 0
|
||||
let metadataVisits = 0
|
||||
let metadataKeys = 0
|
||||
const repos = h.repos.slice()
|
||||
repos.forEach((repo, index) => {
|
||||
Object.defineProperty(repos, index, {
|
||||
get() {
|
||||
repoVisits++
|
||||
return repo
|
||||
}
|
||||
})
|
||||
})
|
||||
const metadata = new Proxy(h.metadata, {
|
||||
get(target, key) {
|
||||
if (typeof key === 'string') {
|
||||
metadataVisits++
|
||||
return target[key]
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
ownKeys(target) {
|
||||
const keys = Reflect.ownKeys(target)
|
||||
metadataKeys += keys.length
|
||||
return keys
|
||||
}
|
||||
})
|
||||
vi.spyOn(h.store, 'getRepos').mockImplementation(() => repos)
|
||||
vi.spyOn(h.store, 'getAllWorktreeMeta').mockImplementation(() => metadata)
|
||||
const projection = vi.spyOn(h.runtime, 'projectCachedWorktrees')
|
||||
const before = h.counts()
|
||||
const start = performance.now()
|
||||
let answer: unknown
|
||||
for (let read = 0; read < 4; read++) {
|
||||
answer = await h.current()
|
||||
}
|
||||
const elapsedMs = performance.now() - start
|
||||
expect(answer).toMatchObject({
|
||||
models: expect.arrayContaining([{ ...models[0], isDefault: true }])
|
||||
})
|
||||
expect(h.counts()).toEqual(before)
|
||||
expect(projection).toHaveBeenCalledTimes(4)
|
||||
expect(repoVisits).toBeGreaterThanOrEqual(repoCount)
|
||||
expect(repoVisits).toBeLessThanOrEqual(4 * (repoCount ** 2 + 10 * repoCount))
|
||||
expect(metadataVisits).toBeLessThanOrEqual(4 * repoCount * 4)
|
||||
expect(metadataKeys).toBeGreaterThan(0)
|
||||
expect(metadataKeys).toBeLessThanOrEqual(4 * (repoCount / 10) * (repoCount * 4))
|
||||
expect(
|
||||
projection.mock.results.every(
|
||||
(result) => result.type === 'return' && result.value.worktrees.length === repoCount * 4
|
||||
)
|
||||
).toBe(true)
|
||||
process.stdout.write(
|
||||
`CATALOG_CORPUS ${JSON.stringify({
|
||||
repoCount,
|
||||
folderRepos: repoCount / 10,
|
||||
gitRows,
|
||||
workspaceRows: repoCount * 4,
|
||||
modelRows: models.length,
|
||||
reads: 4,
|
||||
projections: projection.mock.calls.length,
|
||||
repoVisits,
|
||||
metadataVisits,
|
||||
metadataKeys,
|
||||
responseBytes: Buffer.byteLength(JSON.stringify(answer)),
|
||||
elapsedMs,
|
||||
optionalWork: h.counts().map((value, index) => value - before[index])
|
||||
})}\n`
|
||||
)
|
||||
h.catalog.stop()
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -30,20 +30,6 @@ describe.each(['router', 'degraded'] as const)('workspace deletion with %s inven
|
||||
unregisterPty(targetId)
|
||||
})
|
||||
|
||||
it('removes a workspace with no terminals despite an unrelated ambiguous session', async () => {
|
||||
const { provider, currentSessions, legacySessions } = setup(kind)
|
||||
|
||||
await expect(
|
||||
killAllProcessesForWorktree(worktreeId, {
|
||||
localProvider: provider,
|
||||
requirePhysicalStop: true
|
||||
})
|
||||
).resolves.toEqual({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 })
|
||||
expect(currentSessions).toEqual([unrelatedId])
|
||||
expect(legacySessions).toEqual([unrelatedId])
|
||||
await expect(provider.listProcesses()).rejects.toThrow(unrelatedId)
|
||||
})
|
||||
|
||||
it('stops the target terminal and preserves ambiguous sessions in other workspaces', async () => {
|
||||
const { provider, currentSessions, legacySessions } = setup(kind, [targetId])
|
||||
|
||||
@@ -57,20 +43,6 @@ describe.each(['router', 'degraded'] as const)('workspace deletion with %s inven
|
||||
expect(legacySessions).toEqual([unrelatedId])
|
||||
})
|
||||
|
||||
it('still refuses deletion when the target terminal has ambiguous ownership', async () => {
|
||||
const { provider, currentSessions, legacySessions } = setup(kind, [targetId])
|
||||
legacySessions.push(targetId)
|
||||
|
||||
await expect(
|
||||
killAllProcessesForWorktree(worktreeId, {
|
||||
localProvider: provider,
|
||||
requirePhysicalStop: true
|
||||
})
|
||||
).rejects.toThrow(`Terminal session owner could not be verified: ${targetId}`)
|
||||
expect(currentSessions).toContain(targetId)
|
||||
expect(legacySessions).toContain(targetId)
|
||||
})
|
||||
|
||||
it('refuses an ambiguous target even after establishing its legacy input route', async () => {
|
||||
const { provider, currentSessions, legacySessions } = setup(kind)
|
||||
legacySessions.push(targetId)
|
||||
|
||||
@@ -355,117 +355,6 @@ describe('updater', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses a delayed background fallback error after the fallback promise handled it', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
|
||||
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
|
||||
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
|
||||
|
||||
const missingManifest = new Error(
|
||||
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
|
||||
)
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
if (callCount === 1) {
|
||||
queueMicrotask(() => {
|
||||
autoUpdaterMock.emit('error', missingManifest)
|
||||
})
|
||||
return new Promise(() => {})
|
||||
}
|
||||
if (callCount === 2) {
|
||||
setTimeout(() => {
|
||||
autoUpdaterMock.emit('error', missingManifest)
|
||||
}, 10)
|
||||
return Promise.reject(missingManifest)
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = updaterMainWindow(sendMock)
|
||||
const { setupAutoUpdater } = await loadUpdaterModule()
|
||||
|
||||
setupAutoUpdater(mainWindow, { getLastUpdateCheckAt: () => null })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
|
||||
const statuses = sendMock.mock.calls
|
||||
.filter(([channel]) => channel === 'updater:status')
|
||||
.map(([, status]) => status)
|
||||
expect(statuses).toContainEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
sendMock.mockClear()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
const statusesAfterLateError = sendMock.mock.calls
|
||||
.filter(([channel]) => channel === 'updater:status')
|
||||
.map(([, status]) => status)
|
||||
expect(statusesAfterLateError).not.toContainEqual(
|
||||
expect.objectContaining({ state: 'error', message: missingManifest.message })
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60 * 1000)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('suppresses a delayed user fallback error after the fallback promise handled it', async () => {
|
||||
vi.useFakeTimers()
|
||||
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
|
||||
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
|
||||
|
||||
const missingManifest = new Error(
|
||||
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
|
||||
)
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
if (callCount === 1) {
|
||||
queueMicrotask(() => {
|
||||
autoUpdaterMock.emit('error', missingManifest)
|
||||
})
|
||||
return new Promise(() => {})
|
||||
}
|
||||
setTimeout(() => {
|
||||
autoUpdaterMock.emit('error', missingManifest)
|
||||
}, 10)
|
||||
return Promise.reject(missingManifest)
|
||||
})
|
||||
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = updaterMainWindow(sendMock)
|
||||
const { setupAutoUpdater, checkForUpdatesFromMenu } = await loadUpdaterModule()
|
||||
|
||||
setupAutoUpdater(mainWindow, { getLastUpdateCheckAt: () => Date.now() })
|
||||
checkForUpdatesFromMenu()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const statuses = sendMock.mock.calls
|
||||
.filter(([channel]) => channel === 'updater:status')
|
||||
.map(([, status]) => status)
|
||||
expect(statuses).toContainEqual({
|
||||
state: 'error',
|
||||
message: "Couldn't reach the update server. Try again in a few minutes.",
|
||||
messageCode: 'updateServerUnreachable',
|
||||
userInitiated: true
|
||||
})
|
||||
})
|
||||
|
||||
sendMock.mockClear()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
const statusesAfterLateError = sendMock.mock.calls
|
||||
.filter(([channel]) => channel === 'updater:status')
|
||||
.map(([, status]) => status)
|
||||
expect(statusesAfterLateError).not.toContainEqual(
|
||||
expect.objectContaining({ state: 'error', message: missingManifest.message })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps background prerelease fallback not-available on the short retry cadence', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
|
||||
@@ -670,29 +559,4 @@ describe('updater', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Moving latest redirects can resolve old manifests' relative asset URLs against newer releases.
|
||||
it('pins the generic feed to a concrete stable tag for a stable user', async () => {
|
||||
appMock.getVersion.mockReturnValue('1.3.17')
|
||||
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18'])
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
|
||||
const { setupAutoUpdater, checkForUpdatesFromMenu } = await loadUpdaterModule()
|
||||
|
||||
const mainWindow = updaterMainWindow(vi.fn())
|
||||
setupAutoUpdater(mainWindow, { getLastUpdateCheckAt: () => Date.now() })
|
||||
|
||||
checkForUpdatesFromMenu()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17', 1, {
|
||||
includePrerelease: false
|
||||
})
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
|
||||
provider: 'generic',
|
||||
url: 'https://github.com/stablyai/orca/releases/download/v1.3.18'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -207,15 +207,6 @@ describe('host model catalog read', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('asks a host that reports no listing nothing more', async () => {
|
||||
answerCatalog([() => Promise.resolve(UNKNOWN)])
|
||||
const { result, unmount } = renderOptions()
|
||||
await flush()
|
||||
expect(catalogReads()).toHaveLength(1)
|
||||
expect(usable(result.current.optionSnapshot)).toBe(true)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('the running provider list wins over a host listing that lands later', async () => {
|
||||
const live = deferred()
|
||||
const waited = deferred()
|
||||
|
||||
-192
@@ -164,47 +164,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('useNativeChatExternalAttachments', () => {
|
||||
it('keeps a failed paired chat from issuing an upload and says how to retry', async () => {
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const setNotice = vi.fn()
|
||||
const notice = 'Codex chat could not open. Use Retry in the chat, then attach the files again.'
|
||||
const probe = await renderProbe({
|
||||
structuredWorktreeId: 'folder-1',
|
||||
structuredSession: {
|
||||
sessionId: 'codex-1',
|
||||
runtimeEnvironmentId: 'server-1',
|
||||
sessionPending: true,
|
||||
sessionPendingNotice: notice
|
||||
},
|
||||
attachResolvedPaths,
|
||||
setNotice
|
||||
})
|
||||
await act(async () => {
|
||||
await probe.latest().captureExternalDrop()(['/local/a.txt'])
|
||||
})
|
||||
expect(setNotice).toHaveBeenCalledWith(notice)
|
||||
expect(mocks.prepareNativeChatSessionAttachmentUpload).not.toHaveBeenCalled()
|
||||
expect(mocks.uploadNativeChatSessionAttachmentPaths).not.toHaveBeenCalled()
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches local worktree paths unchanged', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' })
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const probe = await renderProbe({ attachResolvedPaths })
|
||||
await act(async () => {
|
||||
probe.latest().attachExternalPaths(['/local/a.txt'])
|
||||
})
|
||||
expect(mocks.stat).toHaveBeenCalledExactlyOnceWith({
|
||||
filePath: '/local/a.txt',
|
||||
access: { kind: 'user-file' }
|
||||
})
|
||||
expect(attachResolvedPaths).toHaveBeenCalledWith(['/local/a.txt'], undefined, {
|
||||
destinationIsCurrent: expect.any(Function),
|
||||
operation: expect.any(Object)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a whole batch over the drop path limit, like a drop does', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' })
|
||||
const attachResolvedPaths = vi.fn()
|
||||
@@ -331,32 +290,6 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the not-ready notice instead of attaching unresolved paths', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'not-ready' })
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const setNotice = vi.fn()
|
||||
const probe = await renderProbe({ attachResolvedPaths, setNotice })
|
||||
await act(async () => {
|
||||
probe.latest().attachExternalPaths(['/local/a.txt'])
|
||||
})
|
||||
expect(setNotice).toHaveBeenCalledWith('Worktree not ready — try again in a moment.')
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not attach client-local paths to a remote runtime', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'remote' })
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const setNotice = vi.fn()
|
||||
const probe = await renderProbe({ attachResolvedPaths, setNotice })
|
||||
await act(async () => {
|
||||
probe.latest().attachExternalPaths(['/local/a.txt'])
|
||||
})
|
||||
expect(setNotice).toHaveBeenCalledWith(
|
||||
'Local attachments are not available for remote sessions.'
|
||||
)
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases browser files a refused attach held, so none stay pinned in the page', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'remote' })
|
||||
const file = new File(['a'], 'a.txt')
|
||||
@@ -368,17 +301,6 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
expect(takeBrowserAttachmentFile(token!)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores local attachment insertion while already disabled', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' })
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const probe = await renderProbe({ disabled: true, attachResolvedPaths })
|
||||
|
||||
act(() => probe.latest().attachExternalPaths(['/local/a.txt']))
|
||||
|
||||
expect(mocks.resolveNativeChatAttachmentOwner).not.toHaveBeenCalled()
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases browser files a picker returns to an already disabled composer', async () => {
|
||||
const [token] = holdBrowserAttachmentFiles([new File(['a'], 'a.txt')])
|
||||
const probe = await renderProbe({ disabled: true, attachResolvedPaths: vi.fn() })
|
||||
@@ -439,48 +361,6 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
mocks.uploadNativeChatSessionAttachmentPaths.mockReset()
|
||||
})
|
||||
|
||||
it('uploads into the chat store and attaches only server paths', async () => {
|
||||
const upload = deferred<AgentSessionAttachmentPathUploadResult>()
|
||||
mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise)
|
||||
const chips = trackingChips()
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const probe = await renderProbe({
|
||||
structuredWorktreeId: 'worktree-1',
|
||||
structuredSession: session,
|
||||
attachResolvedPaths,
|
||||
pendingChips: chips
|
||||
})
|
||||
|
||||
act(() => probe.latest().attachExternalPaths(['/Users/me/shot.png', '/Users/me/notes.md']))
|
||||
expect(chips.begun).toEqual(['shot.png', 'notes.md'])
|
||||
await act(async () =>
|
||||
upload.resolve(
|
||||
uploaded([
|
||||
['/Users/me/shot.png', '/srv/agent-session-attachments/u1/shot.png'],
|
||||
['/Users/me/notes.md', '/srv/agent-session-attachments/u2/notes.md']
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
expect(mocks.uploadNativeChatSessionAttachmentPaths).toHaveBeenCalledWith(
|
||||
['/Users/me/shot.png', '/Users/me/notes.md'],
|
||||
target
|
||||
)
|
||||
expect(chips.resolve).toHaveBeenCalledExactlyOnceWith(
|
||||
'chip-1',
|
||||
'/srv/agent-session-attachments/u1/shot.png',
|
||||
null,
|
||||
undefined
|
||||
)
|
||||
expect(chips.drop).not.toHaveBeenCalled()
|
||||
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith(
|
||||
[{ id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' }],
|
||||
undefined
|
||||
)
|
||||
// The client never reads its own disk for these paths: the server stores the bytes.
|
||||
expect(mocks.stat).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([null, 'env-1'])('fences captured results for %s', async (runtimeEnvironmentId) => {
|
||||
const chips = trackingChips()
|
||||
const attachResolvedPaths = vi.fn()
|
||||
@@ -557,38 +437,6 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps each file operation ID through reference settlement, including removed chips', async () => {
|
||||
const upload = deferred<AgentSessionAttachmentPathUploadResult>()
|
||||
mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise)
|
||||
const chips = trackingChips()
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const probe = await renderProbe({
|
||||
structuredWorktreeId: 'worktree-1',
|
||||
structuredSession: session,
|
||||
attachResolvedPaths,
|
||||
pendingChips: chips
|
||||
})
|
||||
|
||||
act(() => probe.latest().attachExternalPaths(['/Users/me/report.pdf', '/Users/me/notes.md']))
|
||||
chips.removed.add('chip-1')
|
||||
await act(async () =>
|
||||
upload.resolve(
|
||||
uploaded([
|
||||
['/Users/me/report.pdf', '/srv/agent-session-attachments/u1/report.pdf'],
|
||||
['/Users/me/notes.md', '/srv/agent-session-attachments/u2/notes.md']
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith(
|
||||
[
|
||||
{ id: 'chip-1', path: '/srv/agent-session-attachments/u1/report.pdf' },
|
||||
{ id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' }
|
||||
],
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('names, in one notice, each file that did not attach', async () => {
|
||||
mocks.uploadNativeChatSessionAttachmentPaths.mockResolvedValueOnce(
|
||||
uploaded(
|
||||
@@ -622,30 +470,6 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
expect(mocks.toastLoading).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses on a server without the attachment store, uploading nothing', async () => {
|
||||
mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
notice: 'needs newer server'
|
||||
})
|
||||
const chips = trackingChips()
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const notices: (string | null)[] = []
|
||||
const probe = await renderProbe({
|
||||
structuredWorktreeId: 'worktree-1',
|
||||
structuredSession: session,
|
||||
attachResolvedPaths,
|
||||
pendingChips: chips,
|
||||
setNotice: (notice) => notices.push(notice)
|
||||
})
|
||||
|
||||
await act(async () => probe.latest().attachExternalPaths(['/Users/me/shot.png']))
|
||||
|
||||
expect(mocks.uploadNativeChatSessionAttachmentPaths).not.toHaveBeenCalled()
|
||||
expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-1')
|
||||
expect(chips.attachReferences).not.toHaveBeenCalled()
|
||||
expect(notices.at(-1)).toBe('needs newer server')
|
||||
})
|
||||
|
||||
it('says which file did not attach when the upload itself fails, such as on a re-pair', async () => {
|
||||
// Main refuses every chunk once the pairing changes, so the whole upload call fails.
|
||||
mocks.uploadNativeChatSessionAttachmentPaths.mockRejectedValueOnce(
|
||||
@@ -669,21 +493,5 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
expect(chips.attachReferences).not.toHaveBeenCalled()
|
||||
expect(notices).toEqual(["Couldn't attach shot.png. Runtime environment changed."])
|
||||
})
|
||||
|
||||
it('keeps a local structured chat on the worktree owner', async () => {
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const probe = await renderProbe({
|
||||
structuredWorktreeId: 'worktree-1',
|
||||
structuredSession: { sessionId: 'session-1', runtimeEnvironmentId: null },
|
||||
attachResolvedPaths
|
||||
})
|
||||
await act(async () => probe.latest().attachExternalPaths(['/local/a.txt']))
|
||||
expect(mocks.resolveNativeChatAttachmentOwnerForWorktree).toHaveBeenCalled()
|
||||
expect(mocks.prepareNativeChatSessionAttachmentUpload).not.toHaveBeenCalled()
|
||||
expect(attachResolvedPaths).toHaveBeenCalledWith(['/local/a.txt'], undefined, {
|
||||
destinationIsCurrent: expect.any(Function),
|
||||
operation: expect.any(Object)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
-25
@@ -147,23 +147,6 @@ describe('attachment guard follows what the host claims', () => {
|
||||
expect(structuredTransport.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['codex', '/permissions'],
|
||||
['claude', '/fast'],
|
||||
['claude', '/init'],
|
||||
['claude', '/review'],
|
||||
['codex', '/goal ship the fix']
|
||||
] as const)('sends %s attachments along with the passed-through %s', async (agent, text) => {
|
||||
const { send, structuredTransport } = harness(agent)
|
||||
send(text)
|
||||
await vi.waitFor(() =>
|
||||
expect(structuredTransport.send).toHaveBeenCalledWith(text, [ATTACHMENT])
|
||||
)
|
||||
expect(structuredTransport.onError).not.toHaveBeenCalledWith(
|
||||
'Remove attachments before using a chat-session command.'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses attachments on /goal where the host sets the goal, since no message is sent', () => {
|
||||
const setObjective = vi.fn(async () => true)
|
||||
const { send, structuredTransport } = harness('codex', { setObjective })
|
||||
@@ -205,14 +188,6 @@ describe('reports the sends that bring the latest into view', () => {
|
||||
expect(structuredTransport.send).toHaveBeenCalledWith('hello', [])
|
||||
expect(onSubmitted).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports a provider command sent without an inventory', async () => {
|
||||
const onSubmitted = vi.fn()
|
||||
const { send, structuredTransport } = harness('codex', undefined, onSubmitted)
|
||||
await send('/permissions', [])
|
||||
expect(structuredTransport.send).toHaveBeenCalledWith('/permissions', [])
|
||||
expect(onSubmitted).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
// STA-10671: an older paired host would run the turn without the approval the chat asks for.
|
||||
|
||||
-19
@@ -403,25 +403,6 @@ describe('Git Bash console-capacity pane retention', () => {
|
||||
expect(started.deps.onPaneProcessDied).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tears down an ordinary failed shell after acknowledged input', async () => {
|
||||
const started = await startPane()
|
||||
started.spawn()
|
||||
sendTerminalInputThroughPane(started.pane, 'exit\r')
|
||||
await flushAsyncTicks()
|
||||
started.output('bash: missing command')
|
||||
started.exit()
|
||||
expect(started.deps.onPaneProcessDied).not.toHaveBeenCalled()
|
||||
expect(started.deps.onPtyExitRef.current).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses existing newborn handling when no overlay handler is installed', async () => {
|
||||
const started = await startPane({ handler: false })
|
||||
started.spawn()
|
||||
started.output(MARKER)
|
||||
expect(() => started.exit()).not.toThrow()
|
||||
expect(started.manager.closePane).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keys an input attempt to the bound process rather than its incoming replacement', async () => {
|
||||
const exitModule = await import('./pty-connection/pty-exit-hibernate')
|
||||
const install = exitModule.installPtyExitHibernate
|
||||
|
||||
@@ -53,41 +53,6 @@ describe('dispatchTerminalNotification', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses a live pane key when marking inactive worktree attention', () => {
|
||||
dispatchTerminalNotification('wt-primary', {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: 'codex',
|
||||
paneKey
|
||||
})
|
||||
|
||||
expect(window.api.notifications.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: 'agent-task-complete',
|
||||
notificationId: buildAgentNotificationId({
|
||||
worktreeId: 'wt-primary',
|
||||
paneKey,
|
||||
stateStartedAt: mockState.agentStatusByPaneKey[paneKey].stateStartedAt
|
||||
}),
|
||||
worktreeId: 'wt-primary',
|
||||
paneKey,
|
||||
repoLabel: 'orca',
|
||||
worktreeLabel: 'master',
|
||||
terminalTitle: 'codex',
|
||||
isActiveWorktree: false,
|
||||
agentType: 'codex',
|
||||
agentState: 'done',
|
||||
agentPrompt: 'codex-hook-notify',
|
||||
agentLastAssistantMessage: 'Done.'
|
||||
})
|
||||
)
|
||||
expect(mockState.markAgentAttentionUnread).toHaveBeenCalledWith({
|
||||
workspaceId: 'wt-primary',
|
||||
subjectKey: paneKey,
|
||||
groupId: 'tab-1',
|
||||
reason: 'agent-completion'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds the notification id from a completion snapshot, not the pinned working row', () => {
|
||||
const pinnedWorkingStartedAt = NOW - 60_000
|
||||
// Match row agent identity to the event or rejection makes both fallback outcomes pass vacuously.
|
||||
@@ -258,87 +223,6 @@ describe('dispatchTerminalNotification', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('hands the completion to the attention module whatever the pane-attention setting', () => {
|
||||
mockState.settings.experimentalTerminalAttention = false
|
||||
|
||||
dispatchTerminalNotification('wt-primary', {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: 'codex',
|
||||
paneKey
|
||||
})
|
||||
|
||||
expect(window.api.notifications.dispatch).toHaveBeenCalled()
|
||||
expect(mockState.markAgentAttentionUnread).toHaveBeenCalledWith({
|
||||
workspaceId: 'wt-primary',
|
||||
subjectKey: paneKey,
|
||||
groupId: 'tab-1',
|
||||
reason: 'agent-completion'
|
||||
})
|
||||
})
|
||||
|
||||
it('offers attention-only completion to main for independent mobile delivery', () => {
|
||||
mockState.settings.notifications = { ...mockState.settings.notifications, enabled: false }
|
||||
dispatchTerminalNotification('wt-primary', {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: 'codex',
|
||||
paneKey
|
||||
})
|
||||
|
||||
expect(mockState.markAgentAttentionUnread).toHaveBeenCalledWith({
|
||||
workspaceId: 'wt-primary',
|
||||
subjectKey: paneKey,
|
||||
groupId: 'tab-1',
|
||||
reason: 'agent-completion'
|
||||
})
|
||||
expect(window.api.notifications.dispatch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names the server running the workspace so a phone opens it there, and none for local work', () => {
|
||||
for (const workspaceOwner of [
|
||||
{ executionHostId: 'local' as const, runtimeEnvironmentId: null },
|
||||
// A server's own SSH target still runs behind that server.
|
||||
{ executionHostId: 'ssh:qa' as const, runtimeEnvironmentId: 'env-1' },
|
||||
// A tab stamped with the server before its environment is known.
|
||||
{ executionHostId: 'runtime:env-2' as const, runtimeEnvironmentId: null }
|
||||
]) {
|
||||
dispatchTerminalNotification('wt-primary', {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: 'codex',
|
||||
paneKey,
|
||||
workspaceOwner
|
||||
})
|
||||
}
|
||||
const calls = vi.mocked(window.api.notifications.dispatch).mock.calls
|
||||
expect(calls.map(([request]) => request.executionHost)).toEqual([
|
||||
undefined,
|
||||
'runtime:env-1',
|
||||
'runtime:env-2'
|
||||
])
|
||||
})
|
||||
|
||||
it('writes unread with the completion reason while the agent-complete banner toggle is off', () => {
|
||||
// Main applies desktop-banner settings after unread/mobile delivery; disabling banners must preserve tray/unread attention.
|
||||
mockState.settings.notifications = {
|
||||
...mockState.settings.notifications,
|
||||
enabled: true,
|
||||
agentTaskComplete: false
|
||||
}
|
||||
|
||||
dispatchTerminalNotification('wt-primary', {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: 'codex',
|
||||
paneKey
|
||||
})
|
||||
|
||||
expect(mockState.markAgentAttentionUnread).toHaveBeenCalledWith({
|
||||
workspaceId: 'wt-primary',
|
||||
subjectKey: paneKey,
|
||||
groupId: 'tab-1',
|
||||
reason: 'agent-completion'
|
||||
})
|
||||
expect(window.api.notifications.dispatch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records a bell as a distinct reason and never as an agent completion', () => {
|
||||
dispatchTerminalNotification('wt-primary', { source: 'terminal-bell', paneKey })
|
||||
|
||||
@@ -615,54 +499,6 @@ describe('dispatchTerminalNotification', () => {
|
||||
expect(dispatchArgs?.agentLastAssistantMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not reuse an untyped fresh agent snapshot when the terminal title names an agent', () => {
|
||||
mockState.agentStatusByPaneKey[paneKey] = makeAgentStatus(paneKey, {
|
||||
agentType: undefined,
|
||||
terminalTitle: 'unknown',
|
||||
lastAssistantMessage: 'Previous agent done.'
|
||||
})
|
||||
|
||||
dispatchTerminalNotification('wt-primary', {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: 'Claude Code',
|
||||
paneKey
|
||||
})
|
||||
|
||||
const dispatchArgs = getLastNotificationDispatchArg()
|
||||
expect(dispatchArgs).toEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-task-complete',
|
||||
worktreeId: 'wt-primary',
|
||||
paneKey,
|
||||
terminalTitle: 'Claude Code'
|
||||
})
|
||||
)
|
||||
expect(dispatchArgs?.agentType).toBeUndefined()
|
||||
expect(dispatchArgs?.agentLastAssistantMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a fresh agent snapshot when the terminal title matches the stored agent', () => {
|
||||
mockState.agentStatusByPaneKey[paneKey] = makeAgentStatus(paneKey, {
|
||||
agentType: 'codex',
|
||||
terminalTitle: 'Codex',
|
||||
lastAssistantMessage: 'Codex done.'
|
||||
})
|
||||
|
||||
dispatchTerminalNotification('wt-primary', {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: '⠋ Codex',
|
||||
paneKey
|
||||
})
|
||||
|
||||
const dispatchArgs = getLastNotificationDispatchArg()
|
||||
expect(dispatchArgs).toEqual(
|
||||
expect.objectContaining({
|
||||
agentType: 'codex',
|
||||
agentLastAssistantMessage: 'Codex done.'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a title-only completion when fresh hook state is still active', () => {
|
||||
mockState.agentStatusByPaneKey[paneKey] = makeAgentStatus(paneKey, {
|
||||
state: 'working',
|
||||
|
||||
@@ -280,80 +280,6 @@ describe('picker window launch contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'passes %s speed through the real picker-to-terminal compiler',
|
||||
async (agent) => {
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
const { agentPickerDraft, agentPickerLaunchInputs } =
|
||||
await import('../../../shared/agent-launch-picker')
|
||||
const { editAgentPickerSpeed } = await import('../../../shared/agent-launch-picker-arguments')
|
||||
const options: Record<string, string> =
|
||||
agent === 'claude' ? { fastMode: 'false' } : { serviceTier: 'default' }
|
||||
const entry = {
|
||||
agent,
|
||||
label: agent,
|
||||
rows: [{ agent, kind: 'terminal' as const }],
|
||||
surfaces: ['chat' as const, 'terminal' as const],
|
||||
defaultSurface: 'chat' as const,
|
||||
terminal: { permissionMode: 'ask' as const },
|
||||
structured: {
|
||||
model: agent === 'claude' ? 'opus' : 'account-model',
|
||||
permissionMode: 'ask' as const,
|
||||
options
|
||||
},
|
||||
arguments:
|
||||
agent === 'claude'
|
||||
? '--settings ./team.json --settings \'{"language":"french"}\''
|
||||
: '--add-dir docs',
|
||||
catalog: {
|
||||
origin: 'probe' as const,
|
||||
fetchedAt: 1,
|
||||
models: [
|
||||
{
|
||||
id: agent === 'claude' ? 'opus' : 'account-model',
|
||||
label: 'Model',
|
||||
isDefault: true,
|
||||
efforts: [],
|
||||
supportsFastMode: true,
|
||||
...(agent === 'codex'
|
||||
? { serviceTiers: [{ value: 'account_fast_v9', label: 'Fast' }] }
|
||||
: {})
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Object.assign(store.settings, { agentPermissionDefault: 'ask' })
|
||||
const draft = editAgentPickerSpeed(
|
||||
entry,
|
||||
agentPickerDraft(entry),
|
||||
agent === 'claude' ? 'fastMode' : 'serviceTier',
|
||||
agent === 'claude' ? 'true' : 'account_fast_v9',
|
||||
entry.structured.model
|
||||
)
|
||||
const launch = {
|
||||
...draft,
|
||||
surface: 'terminal' as const
|
||||
}
|
||||
mockCreateTab.mockClear()
|
||||
launchAgentInNewTab({
|
||||
requestId: 'picker-fast-contract',
|
||||
agent,
|
||||
worktreeId: 'wt-1',
|
||||
requestedSurface: 'terminal',
|
||||
...agentPickerLaunchInputs(launch, entry)
|
||||
})
|
||||
const command = queuedStartups()[0]?.command ?? ''
|
||||
expect(command).toContain(
|
||||
agent === 'claude' ? '"fastMode":true' : 'service_tier=account_fast_v9'
|
||||
)
|
||||
if (agent === 'claude') {
|
||||
expect(command).toContain('"language":"french"')
|
||||
expect(command).toContain('./team.json')
|
||||
}
|
||||
expect(command).not.toContain('account-model')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'keeps failed %s defaults unknown through acquisition, controls, projection and actual dispatch, then wakes to a permission-preserving edit',
|
||||
async (agent) => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getLocalPreflightContext,
|
||||
getLocalProjectExecutionRuntimeContext,
|
||||
getLocalRepoProjectExecutionRuntimeContext,
|
||||
getWslDistroFromPath,
|
||||
localPreflightContextKey
|
||||
} from './local-preflight-context'
|
||||
|
||||
@@ -76,12 +75,6 @@ function makeState(args: {
|
||||
}
|
||||
|
||||
describe('local preflight context', () => {
|
||||
it('extracts WSL distro names from supported UNC forms', () => {
|
||||
expect(getWslDistroFromPath(String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`)).toBe('Ubuntu')
|
||||
expect(getWslDistroFromPath(String.raw`\\wsl$\Debian\home\alice\repo`)).toBe('Debian')
|
||||
expect(getWslDistroFromPath('/Users/alice/repo')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a stable snapshot for repeated WSL selector reads', () => {
|
||||
const state = makeState({
|
||||
repoPath: '/Users/alice/repo',
|
||||
@@ -112,41 +105,6 @@ describe('local preflight context', () => {
|
||||
expect(getLocalPreflightContext(state, 'darwin')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reuses the same WSL snapshot across equivalent active repo and worktree paths', () => {
|
||||
const fromRepo = getLocalPreflightContext(
|
||||
makeState({
|
||||
repoPath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`
|
||||
}),
|
||||
'darwin'
|
||||
)
|
||||
const fromWorktree = getLocalPreflightContext(
|
||||
makeState({
|
||||
repoPath: '/Users/alice/repo',
|
||||
worktreePath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`
|
||||
}),
|
||||
'darwin'
|
||||
)
|
||||
const fromOtherDistro = getLocalPreflightContext(
|
||||
makeState({
|
||||
repoPath: '/Users/alice/repo',
|
||||
worktreePath: String.raw`\\wsl.localhost\Debian\home\alice\repo`
|
||||
}),
|
||||
'darwin'
|
||||
)
|
||||
|
||||
expect(fromRepo).toBe(fromWorktree)
|
||||
expect(fromOtherDistro).not.toBe(fromRepo)
|
||||
expect(fromOtherDistro).toEqual({ wslDistro: 'Debian' })
|
||||
})
|
||||
|
||||
it('uses the stable host context for non-WSL paths', () => {
|
||||
const state = makeState({ repoPath: '/Users/alice/repo' })
|
||||
|
||||
expect(getLocalPreflightContext(state)).toBeUndefined()
|
||||
expect(getLocalPreflightContext(state)).toBeUndefined()
|
||||
expect(localPreflightContextKey(getLocalPreflightContext(state))).toBe('host')
|
||||
})
|
||||
|
||||
it('keys preflight by active runtime before local WSL or host context', () => {
|
||||
const state: AppState = {
|
||||
...makeState({
|
||||
@@ -530,30 +488,6 @@ describe('local preflight context', () => {
|
||||
expect(localPreflightContextKey(context)).toBe('repo-2:windows-host')
|
||||
})
|
||||
|
||||
it('resolves a project host override for a specific worktree over a WSL default', () => {
|
||||
const state: AppState = {
|
||||
...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }),
|
||||
settings: {
|
||||
...legacySettings(),
|
||||
terminalWindowsShell: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian',
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' }
|
||||
},
|
||||
projects: [project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } })]
|
||||
}
|
||||
|
||||
expect(getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32')).toEqual({
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'windows-host',
|
||||
hostPlatform: 'win32',
|
||||
projectId: 'repo-1',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'repo-1:windows-host'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not assign the active project runtime to the floating workspace', () => {
|
||||
const state: AppState = {
|
||||
...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }),
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/** Pin native follow/pin ownership through shared headless/core BufferService. Live writes rely on xterm; app enforcement handles structural replay/remount/reflow only. Upgrades must preserve this contract. Since beta.302 (#6081), CSI 3 J releases native pins; Orca observes before erase to capture and restore the live reading offset. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import headlessPackageJson from '@xterm/headless/package.json'
|
||||
import xtermPackageJson from '@xterm/xterm/package.json'
|
||||
import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear'
|
||||
import { installTerminalLiveScrollbackRestore } from './terminal-live-scrollback-restore'
|
||||
import { markTerminalFollowOutput, markTerminalPinnedViewport } from './terminal-scroll-intent'
|
||||
import { markTerminalPinnedViewport } from './terminal-scroll-intent'
|
||||
|
||||
type TerminalWithBufferService = Terminal & {
|
||||
_core?: {
|
||||
@@ -39,129 +37,6 @@ async function pinnedScrollbackTerminal(): Promise<TerminalWithBufferService> {
|
||||
}
|
||||
|
||||
describe('xterm native user-scrolling contract', () => {
|
||||
it('builds headless and renderer xterm from the same upstream commit', () => {
|
||||
// Compare source commits, not versions: headless/core may publish different betas with identical BufferService/BufferLine code; publish.js stamps both.
|
||||
expect(headlessPackageJson.commit).toBe(xtermPackageJson.commit)
|
||||
expect(xtermPackageJson.commit).toMatch(/^[0-9a-f]{40}$/)
|
||||
})
|
||||
|
||||
it('keeps a scrolled-up viewport stable while output is written', async () => {
|
||||
const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true })
|
||||
await writeLines(term, 30, 'line')
|
||||
const buffer = term.buffer.active
|
||||
expect(buffer.viewportY).toBe(buffer.baseY)
|
||||
|
||||
term.scrollLines(-5)
|
||||
const pinnedY = buffer.viewportY
|
||||
expect(pinnedY).toBe(buffer.baseY - 5)
|
||||
|
||||
await writeLines(term, 10, 'more')
|
||||
expect(buffer.viewportY).toBe(pinnedY)
|
||||
expect(buffer.baseY).toBe(pinnedY + 15)
|
||||
})
|
||||
|
||||
it('treats a viewport one row above bottom as user-scrolling through output', async () => {
|
||||
const term = new Terminal({
|
||||
rows: 10,
|
||||
cols: 40,
|
||||
scrollback: 1000,
|
||||
allowProposedApi: true
|
||||
}) as TerminalWithBufferService
|
||||
await writeLines(term, 30, 'line')
|
||||
const buffer = term.buffer.active
|
||||
|
||||
term.scrollLines(-1)
|
||||
const pinnedY = buffer.viewportY
|
||||
expect(pinnedY).toBe(buffer.baseY - 1)
|
||||
expect(term._core?._bufferService?.isUserScrolling).toBe(true)
|
||||
|
||||
await writeLines(term, 5, 'more')
|
||||
expect(buffer.viewportY).toBe(pinnedY)
|
||||
})
|
||||
|
||||
it('follows output at the bottom and re-follows after scrolling back down', async () => {
|
||||
const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true })
|
||||
await writeLines(term, 30, 'line')
|
||||
const buffer = term.buffer.active
|
||||
|
||||
await writeLines(term, 5, 'tail')
|
||||
expect(buffer.viewportY).toBe(buffer.baseY)
|
||||
|
||||
term.scrollLines(-5)
|
||||
term.scrollToBottom()
|
||||
await writeLines(term, 5, 'after')
|
||||
expect(buffer.viewportY).toBe(buffer.baseY)
|
||||
})
|
||||
|
||||
it('applies scrollOnUserInput before notifying onData listeners', async () => {
|
||||
const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true })
|
||||
await writeLines(term, 30, 'line')
|
||||
const buffer = term.buffer.active
|
||||
term.scrollLines(-5)
|
||||
let viewportSeenByOnData = -1
|
||||
const subscription = term.onData(() => {
|
||||
viewportSeenByOnData = buffer.viewportY
|
||||
})
|
||||
|
||||
term.input('a', true)
|
||||
|
||||
// Typing-intent resync runs in onData, making this xterm event order part of the pinned contract.
|
||||
expect(viewportSeenByOnData).toBe(buffer.baseY)
|
||||
subscription.dispose()
|
||||
})
|
||||
|
||||
it('distinguishes real user input from parser auto-replies', async () => {
|
||||
const term = new Terminal({
|
||||
rows: 10,
|
||||
cols: 40,
|
||||
allowProposedApi: true
|
||||
}) as TerminalWithBufferService
|
||||
expect(term._core?.coreService?.onUserInput).toBeTypeOf('function')
|
||||
let userInputCount = 0
|
||||
const subscription = term._core?.coreService?.onUserInput?.(() => {
|
||||
userInputCount += 1
|
||||
})
|
||||
|
||||
term.input('a', true)
|
||||
await write(term, '\x1b[6n')
|
||||
|
||||
expect(userInputCount).toBe(1)
|
||||
subscription?.dispose()
|
||||
})
|
||||
|
||||
it('walks a pinned viewport down content-stably when scrollback trims', async () => {
|
||||
const term = new Terminal({ rows: 5, cols: 20, scrollback: 20, allowProposedApi: true })
|
||||
await writeLines(term, 30, 'x')
|
||||
const buffer = term.buffer.active
|
||||
term.scrollLines(-10)
|
||||
const pinnedY = buffer.viewportY
|
||||
const fullBaseY = buffer.baseY
|
||||
|
||||
await writeLines(term, 10, 'trim')
|
||||
// At capacity, fixed baseY plus trimming decrements viewportY to preserve content.
|
||||
expect(buffer.baseY).toBe(fullBaseY)
|
||||
expect(buffer.viewportY).toBe(Math.max(0, pinnedY - 10))
|
||||
})
|
||||
|
||||
it('exposes the isUserScrolling flag the structural restore paths depend on', async () => {
|
||||
const term = new Terminal({
|
||||
rows: 10,
|
||||
cols: 40,
|
||||
scrollback: 1000,
|
||||
allowProposedApi: true
|
||||
}) as TerminalWithBufferService
|
||||
await writeLines(term, 30, 'line')
|
||||
const bufferService = term._core?._bufferService
|
||||
expect(typeof bufferService?.isUserScrolling).toBe('boolean')
|
||||
|
||||
// Public scroll APIs manage isUserScrolling; structural restores inherit native live-output ownership.
|
||||
expect(bufferService?.isUserScrolling).toBe(false)
|
||||
term.scrollLines(-5)
|
||||
expect(bufferService?.isUserScrolling).toBe(true)
|
||||
term.scrollToBottom()
|
||||
expect(bufferService?.isUserScrolling).toBe(false)
|
||||
})
|
||||
|
||||
it('resets native user-scrolling when a pinned scrollback is cleared', async () => {
|
||||
const term = new Terminal({
|
||||
rows: 10,
|
||||
@@ -277,17 +152,6 @@ describe('live scrollback-erase pin (CSI 3 J)', () => {
|
||||
expect(term._core?._bufferService?.isUserScrolling).toBe(true)
|
||||
})
|
||||
|
||||
it('triggers on a bare erase-scrollback with no screen clear alongside it', async () => {
|
||||
const { term, settle, bottomOffset, scheduleCount } = await pinnedTerminalWithRestore()
|
||||
|
||||
// No \x1b[2J prefix: the scrollback erase alone is the trigger.
|
||||
await write(term, `\x1b[3J${'after\r\n'.repeat(150)}`)
|
||||
expect(scheduleCount()).toBeGreaterThan(0)
|
||||
settle()
|
||||
|
||||
expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset)
|
||||
})
|
||||
|
||||
it('ignores erase-in-display forms that keep the scrollback', async () => {
|
||||
const { term, settle, scheduleCount } = await pinnedTerminalWithRestore()
|
||||
const pinnedY = term.buffer.active.viewportY
|
||||
@@ -311,16 +175,6 @@ describe('live scrollback-erase pin (CSI 3 J)', () => {
|
||||
expect(term.buffer.active.viewportY).toBe(pinnedY)
|
||||
})
|
||||
|
||||
it('detects a clear split across writes', async () => {
|
||||
const { term, settle, bottomOffset } = await pinnedTerminalWithRestore()
|
||||
|
||||
await write(term, '\x1b[2J\x1b[H\x1b[')
|
||||
await write(term, `3J${'after\r\n'.repeat(50)}`)
|
||||
settle()
|
||||
|
||||
expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY - bottomOffset)
|
||||
})
|
||||
|
||||
it('holds the pin while the redraw keeps arriving and lands it once quiet', async () => {
|
||||
const { term, advance, bottomOffset } = await pinnedTerminalWithRestore()
|
||||
|
||||
@@ -335,29 +189,6 @@ describe('live scrollback-erase pin (CSI 3 J)', () => {
|
||||
expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset)
|
||||
})
|
||||
|
||||
it('lands the pin after a redraw that outgrows the erased scrollback over several writes', async () => {
|
||||
const { term, settle, bottomOffset } = await pinnedTerminalWithRestore()
|
||||
|
||||
// Each batch alone would settle the pin at the wrong distance from the bottom.
|
||||
await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(101)}`)
|
||||
await write(term, 'after\r\n'.repeat(199))
|
||||
settle()
|
||||
|
||||
expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset)
|
||||
})
|
||||
|
||||
it('survives a mid-redraw write that adds no rows', async () => {
|
||||
const { term, settle, bottomOffset } = await pinnedTerminalWithRestore()
|
||||
|
||||
await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(50)}`)
|
||||
// A cursor/attribute-only frame is normal mid-repaint and must not end the pin.
|
||||
await write(term, '\x1b[K')
|
||||
await write(term, 'after\r\n'.repeat(250))
|
||||
settle()
|
||||
|
||||
expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset)
|
||||
})
|
||||
|
||||
it('spends the pin once even when the rebuild stays shorter than the old scrollback', async () => {
|
||||
const { term, settle, bottomOffset } = await pinnedTerminalWithRestore()
|
||||
|
||||
@@ -372,19 +203,6 @@ describe('live scrollback-erase pin (CSI 3 J)', () => {
|
||||
expect(term.buffer.active.viewportY).toBe(restoredY)
|
||||
})
|
||||
|
||||
it('does not drag the reader through output that arrives after the pin lands', async () => {
|
||||
const { term, settle, bottomOffset } = await pinnedTerminalWithRestore()
|
||||
|
||||
await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(120)}`)
|
||||
settle()
|
||||
const restoredY = term.buffer.active.viewportY
|
||||
expect(restoredY).toBe(term.buffer.active.baseY - bottomOffset)
|
||||
|
||||
await writeLines(term, 200, 'later')
|
||||
settle()
|
||||
expect(term.buffer.active.viewportY).toBe(restoredY)
|
||||
})
|
||||
|
||||
it('measures the pin from the rows already written earlier in the same chunk', async () => {
|
||||
const { term, settle, bottomOffset } = await pinnedTerminalWithRestore()
|
||||
const rowsBeforeErase = 20
|
||||
@@ -526,15 +344,6 @@ describe('live scrollback-erase pin (CSI 3 J)', () => {
|
||||
expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY - bottomOffset)
|
||||
})
|
||||
|
||||
it('restores after the subparameter erase form', async () => {
|
||||
const { term, settle, bottomOffset } = await pinnedTerminalWithRestore()
|
||||
|
||||
await write(term, `\x1b[2J\x1b[H\x1b[3:1J${'after\r\n'.repeat(120)}`)
|
||||
settle()
|
||||
|
||||
expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset)
|
||||
})
|
||||
|
||||
it('does not arm for an erase on the alternate screen', async () => {
|
||||
const { term, settle, scheduleCount } = await pinnedTerminalWithRestore()
|
||||
const pinnedY = term.buffer.active.viewportY
|
||||
@@ -572,27 +381,6 @@ describe('live scrollback-erase pin (CSI 3 J)', () => {
|
||||
expect(term.buffer.active.viewportY).toBe(afterUserScrollY)
|
||||
})
|
||||
|
||||
it('leaves a follow-output pane following', async () => {
|
||||
const term = new Terminal({
|
||||
rows: 10,
|
||||
cols: 40,
|
||||
scrollback: 1000,
|
||||
allowProposedApi: true
|
||||
}) as TerminalWithBufferService
|
||||
await writeLines(term, 100, 'before')
|
||||
markTerminalFollowOutput(term)
|
||||
const scheduler = manualSettleScheduler()
|
||||
installTerminalLiveScrollbackRestore(term, {
|
||||
now: scheduler.now,
|
||||
scheduleSettle: scheduler.scheduleSettle
|
||||
})
|
||||
|
||||
await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(50)}`)
|
||||
expect(scheduler.scheduleCount()).toBe(0)
|
||||
scheduler.advance(SETTLE_MS)
|
||||
expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY)
|
||||
})
|
||||
|
||||
it('cancels an armed pin on dispose', async () => {
|
||||
const term = await pinnedScrollbackTerminal()
|
||||
const scheduler = manualSettleScheduler()
|
||||
@@ -621,18 +409,6 @@ describe('live scrollback-erase pin (CSI 3 J)', () => {
|
||||
// Without Orca’s pin, xterm’s erase releases reading position and follows the redraw.
|
||||
expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY)
|
||||
})
|
||||
|
||||
it('lands the pin on the real timer when no scheduler is injected', async () => {
|
||||
const term = await pinnedScrollbackTerminal()
|
||||
const restore = installTerminalLiveScrollbackRestore(term)
|
||||
const bottomOffset = term.buffer.active.baseY - term.buffer.active.viewportY
|
||||
|
||||
await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(120)}`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
|
||||
expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset)
|
||||
restore.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('live scrollback-erase pin wiring', () => {
|
||||
@@ -683,16 +459,6 @@ describe('live scrollback-erase pin wiring', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('registers both the plain and private erase idents', () => {
|
||||
const stub = stubTerminal()
|
||||
installTerminalLiveScrollbackRestore(stub.target)
|
||||
|
||||
expect(stub.registrations.map((registration) => registration.id)).toEqual([
|
||||
{ final: 'J' },
|
||||
{ prefix: '?', final: 'J' }
|
||||
])
|
||||
})
|
||||
|
||||
it('degrades a throwing capture to "not handled" instead of wedging the parser', () => {
|
||||
const stub = stubTerminal({ throwOnBuffer: true })
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
@@ -706,20 +472,4 @@ describe('live scrollback-erase pin wiring', () => {
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('releases both registrations and the parsed subscription on dispose', () => {
|
||||
const stub = stubTerminal()
|
||||
const restore = installTerminalLiveScrollbackRestore(stub.target)
|
||||
|
||||
restore.dispose()
|
||||
expect(stub.registrations.every((registration) => registration.disposed)).toBe(true)
|
||||
expect(stub.parsedDisposed()).toBe(true)
|
||||
})
|
||||
|
||||
it('does nothing when the terminal exposes no parser or parsed event', () => {
|
||||
const restore = installTerminalLiveScrollbackRestore({
|
||||
buffer: { active: { type: 'normal', viewportY: 10, baseY: 50 } }
|
||||
} as Parameters<typeof installTerminalLiveScrollbackRestore>[0])
|
||||
expect(() => restore.dispose()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,23 +75,6 @@ function runtimeSnapshot(
|
||||
}
|
||||
|
||||
describe('worktree agent activation gate', () => {
|
||||
windowOwnedIt('uses immediately ready development restore inventory', async () => {
|
||||
const ptyId = `${WORKTREE_ID}@@live-pty`
|
||||
const { deps, createTab, resume } = testDeps({
|
||||
sessions: [listed(ptyId)],
|
||||
surfaceOwners: new Map([[ptyId, UNOWNED]])
|
||||
})
|
||||
const awaitReady = vi.fn(async () => true)
|
||||
|
||||
await expect(
|
||||
runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, awaitReady })
|
||||
).resolves.toBe('adopted')
|
||||
|
||||
expect(awaitReady).toHaveBeenCalledOnce()
|
||||
expect(createTab).toHaveBeenCalledOnce()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
windowOwnedIt(
|
||||
'waits for packaged restore hydration before reading daemon inventory',
|
||||
async () => {
|
||||
@@ -115,18 +98,6 @@ describe('worktree agent activation gate', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('reads structured ownership before adopting daemon PTYs', async () => {
|
||||
const ptyId = `${WORKTREE_ID}@@live-pty`
|
||||
const { deps } = testDeps({ sessions: [listed(ptyId)] })
|
||||
const hasStructuredSession = vi.fn(async () => false)
|
||||
|
||||
await runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession })
|
||||
|
||||
expect(hasStructuredSession.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
deps.listSessions.mock.invocationCallOrder[0] ?? Infinity
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks seeding while the queried snapshot names a live launch', async () => {
|
||||
const { deps, resume } = testDeps({})
|
||||
const snapshot = {
|
||||
@@ -387,29 +358,6 @@ describe('worktree agent activation gate', () => {
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not resume when the runtime reports a structured session before tab sync', async () => {
|
||||
const { deps, createTab, resume } = testDeps({})
|
||||
const hasStructuredSession = vi.fn(async () => true)
|
||||
|
||||
await expect(
|
||||
runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession })
|
||||
).resolves.toBe('structured')
|
||||
|
||||
expect(hasStructuredSession).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the existing resume path when the workspace has no live agent', async () => {
|
||||
const { deps, createTab, resume } = testDeps({})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(resume).toHaveBeenCalledOnce()
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() })
|
||||
})
|
||||
|
||||
it('resumes a dead agent when the workspace only has a non-agent PTY', async () => {
|
||||
const dead = sleepingRecord('tab-dead', DEAD_LEAF_ID, 'dead-session')
|
||||
const plainPtyId = `${WORKTREE_ID}@@plain-shell`
|
||||
@@ -825,21 +773,4 @@ describe('worktree agent activation gate', () => {
|
||||
[SIBLING_LEAF_ID]: secondPtyId
|
||||
})
|
||||
})
|
||||
|
||||
// Layout-owner build: 'makes no surface for a live PTY main has not placed yet, and resumes no agent over it'.
|
||||
windowOwnedIt('still adopts a live PTY the host reports as owned by no surface', async () => {
|
||||
const livePtyId = `${WORKTREE_ID}@@orphan-agent`
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(livePtyId)],
|
||||
surfaceOwners: new Map([[livePtyId, UNOWNED]])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, {
|
||||
initialPtyId: livePtyId,
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,44 +143,6 @@ describe('workspace cleanup removal and protection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards the snapshot batch through each successful removal', async () => {
|
||||
const candidate = makeCandidate({ executionHostId: 'ssh:ssh-1' })
|
||||
installWorkspaceCleanupApi(
|
||||
vi.fn(async () => ({
|
||||
scannedAt: NOW,
|
||||
candidates: [candidate],
|
||||
errors: []
|
||||
}))
|
||||
)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
|
||||
await store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId], {
|
||||
snapshotPruneBatchId: 'batch-1'
|
||||
})
|
||||
|
||||
expect(removeWorktree).toHaveBeenCalledWith(
|
||||
{ id: candidate.worktreeId, executionHostId: null },
|
||||
false,
|
||||
{
|
||||
assertBeforeDispatch: expect.any(Function),
|
||||
suppressPreservedBranchToast: true,
|
||||
snapshotPruneBatchId: 'batch-1'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('demotes an active suggested workspace when it was not viewed from cleanup', async () => {
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(
|
||||
[makeCandidate()],
|
||||
makeState({ activeWorktreeId: WORKTREE_ID }),
|
||||
{ applyDismissals: false }
|
||||
)
|
||||
|
||||
expect(candidate.tier).toBe('protected')
|
||||
expect(candidate.blockers).toContain('active-workspace')
|
||||
})
|
||||
|
||||
it('keeps a viewed active workspace visible but not removable', async () => {
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(
|
||||
[makeCandidate()],
|
||||
@@ -201,25 +163,6 @@ describe('workspace cleanup removal and protection', () => {
|
||||
expect(candidate.blockers).toContain('active-workspace')
|
||||
})
|
||||
|
||||
it('does not preserve the cleanup view exception after the row changes', async () => {
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(
|
||||
[makeCandidate({ fingerprint: 'fingerprint-2' })],
|
||||
makeState({
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
workspaceCleanupViewedCandidates: {
|
||||
[WORKTREE_ID]: {
|
||||
viewedAt: Date.now(),
|
||||
fingerprint: 'fingerprint-1'
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ applyDismissals: false }
|
||||
)
|
||||
|
||||
expect(candidate.tier).toBe('protected')
|
||||
expect(candidate.blockers).toContain('active-workspace')
|
||||
})
|
||||
|
||||
it('protects recently visible old workspaces with open context', async () => {
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(
|
||||
[makeCandidate()],
|
||||
@@ -246,111 +189,6 @@ describe('workspace cleanup removal and protection', () => {
|
||||
expect(candidate.blockers).toContain('recent-visible-context')
|
||||
})
|
||||
|
||||
it('uses current renderer state after async delete preflight scan resolves', async () => {
|
||||
let resolveScan: (value: WorkspaceCleanupScanResult) => void
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: {
|
||||
workspaceCleanup: {
|
||||
scan: vi.fn(
|
||||
(): Promise<WorkspaceCleanupScanResult> =>
|
||||
new Promise<WorkspaceCleanupScanResult>((resolve) => {
|
||||
resolveScan = resolve
|
||||
})
|
||||
),
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
clearDismissals: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removal = store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])
|
||||
store.setState({ activeWorktreeId: WORKTREE_ID })
|
||||
resolveScan!({ scannedAt: NOW, candidates: [makeCandidate()], errors: [] })
|
||||
|
||||
await expect(removal).resolves.toEqual({
|
||||
removedIds: [WORKTREE_ID],
|
||||
removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)],
|
||||
failures: []
|
||||
})
|
||||
expect(removeWorktree).toHaveBeenCalledWith({ id: WORKTREE_ID, executionHostId: null }, false, {
|
||||
assertBeforeDispatch: expect.any(Function),
|
||||
suppressPreservedBranchToast: true
|
||||
})
|
||||
})
|
||||
|
||||
it('defers git checks for locally active workspaces on initial scans', async () => {
|
||||
const scan = vi.fn().mockResolvedValue({
|
||||
scannedAt: NOW,
|
||||
candidates: [],
|
||||
errors: []
|
||||
} satisfies WorkspaceCleanupScanResult)
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: {
|
||||
workspaceCleanup: {
|
||||
scan,
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
clearDismissals: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const store = createCleanupTestStore()
|
||||
store.setState({
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
tabsByWorktree: {
|
||||
'repo1::/tmp/terminal-workspace': [
|
||||
{ id: 'tab-1', title: 'zsh' }
|
||||
] as AppState['tabsByWorktree'][string]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().scanWorkspaceCleanup()
|
||||
|
||||
expect(scan).toHaveBeenCalledWith(
|
||||
{
|
||||
includeAllWorkspaces: true,
|
||||
scanId: expect.any(String),
|
||||
skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace'])
|
||||
},
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not defer git checks for focused remove preflights', async () => {
|
||||
const scan = vi.fn().mockResolvedValue({
|
||||
scannedAt: NOW,
|
||||
candidates: [makeCandidate()],
|
||||
errors: []
|
||||
} satisfies WorkspaceCleanupScanResult)
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: {
|
||||
workspaceCleanup: {
|
||||
scan,
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
clearDismissals: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
store.setState({
|
||||
activeWorktreeId: 'repo1::/tmp/other-workspace'
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])
|
||||
|
||||
expect(scan).toHaveBeenCalledWith({
|
||||
worktreeIds: [WORKTREE_ID],
|
||||
scanId: expect.any(String),
|
||||
refreshActivity: true
|
||||
})
|
||||
})
|
||||
|
||||
it('lets explicitly selected not-suggested workspaces reach the removal path', async () => {
|
||||
const scan = vi.fn().mockResolvedValue({
|
||||
scannedAt: NOW,
|
||||
@@ -517,32 +355,6 @@ describe('workspace cleanup removal and protection', () => {
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('protects old workspaces when an agent process is still foregrounded', async () => {
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: {
|
||||
pty: {
|
||||
hasChildProcesses: vi.fn().mockResolvedValue(true),
|
||||
getForegroundProcess: vi.fn().mockResolvedValue('codex')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(
|
||||
[makeCandidate()],
|
||||
makeState({
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
|
||||
}),
|
||||
{ applyDismissals: false }
|
||||
)
|
||||
|
||||
expect(candidate.tier).toBe('protected')
|
||||
expect(candidate.selectedByDefault).toBe(false)
|
||||
expect(candidate.blockers).toContain('running-terminal')
|
||||
})
|
||||
|
||||
it('does not let an idle title in another tab mask a running agent process', async () => {
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: {
|
||||
|
||||
Reference in New Issue
Block a user