mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
test: remove 287 low-value suites from combined audit (#42659)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -80,7 +80,6 @@ on:
|
||||
- 'src/main/ai-vault/parsing/session-scanner-jsonl-reader.test.ts'
|
||||
- 'src/main/ai-vault/parsing/session-scanner-jsonl-reader.ts'
|
||||
- 'src/main/ai-vault/parsing/session-scanner-messages.ts'
|
||||
- 'src/main/ai-vault/parsing/session-scanner-parse-wsl-stall.test.ts'
|
||||
- 'src/main/ai-vault/parsing/session-scanner-text-normalization.ts'
|
||||
- 'src/main/ai-vault/parsing/session-scanner-token-values.ts'
|
||||
- 'src/main/ai-vault/prompts/session-scanner-first-user-prompt-capture.ts'
|
||||
@@ -94,7 +93,6 @@ on:
|
||||
- 'src/main/ai-vault/scope/session-scanner-scope-inventory.ts'
|
||||
- 'src/main/ai-vault/scope/session-scanner-scope-walk.test.ts'
|
||||
- 'src/main/ai-vault/scope/session-scanner-scope-walk.ts'
|
||||
- 'src/main/ai-vault/scope/session-scanner-scope.test.ts'
|
||||
- 'src/main/ai-vault/session-scanner.ts'
|
||||
- 'src/shared/error-message.ts'
|
||||
- 'src/shared/surrogate-safe-text-slice.ts'
|
||||
@@ -213,7 +211,6 @@ on:
|
||||
- 'src/main/ai-vault/parsing/session-scanner-jsonl-reader.test.ts'
|
||||
- 'src/main/ai-vault/parsing/session-scanner-jsonl-reader.ts'
|
||||
- 'src/main/ai-vault/parsing/session-scanner-messages.ts'
|
||||
- 'src/main/ai-vault/parsing/session-scanner-parse-wsl-stall.test.ts'
|
||||
- 'src/main/ai-vault/parsing/session-scanner-text-normalization.ts'
|
||||
- 'src/main/ai-vault/parsing/session-scanner-token-values.ts'
|
||||
- 'src/main/ai-vault/prompts/session-scanner-first-user-prompt-capture.ts'
|
||||
@@ -227,7 +224,6 @@ on:
|
||||
- 'src/main/ai-vault/scope/session-scanner-scope-inventory.ts'
|
||||
- 'src/main/ai-vault/scope/session-scanner-scope-walk.test.ts'
|
||||
- 'src/main/ai-vault/scope/session-scanner-scope-walk.ts'
|
||||
- 'src/main/ai-vault/scope/session-scanner-scope.test.ts'
|
||||
- 'src/main/ai-vault/session-scanner.ts'
|
||||
- 'src/shared/error-message.ts'
|
||||
- 'src/shared/surrogate-safe-text-slice.ts'
|
||||
|
||||
@@ -114,7 +114,6 @@
|
||||
1 test-no-real-time-wait:src/main/agent-hooks/installation/scripts/posix-hook-json-stdin-reader.test.ts
|
||||
2 test-no-real-time-wait:src/main/agent-hooks/server/testing/http/server-transport-interference.test.ts
|
||||
1 test-no-real-time-wait:src/main/agent-hooks/server/testing/main-agent/server-turn-ended-at.test.ts
|
||||
1 test-no-real-time-wait:src/main/agent-hooks/server/testing/main-agent/server-turn-started-at.test.ts
|
||||
1 test-no-real-time-wait:src/main/ai-vault/scanners/opencode/reader-transport/session-scanner-opencode-sqlite-process.test.ts
|
||||
1 test-no-real-time-wait:src/main/antigravity/windows-hook-json-post.test.ts
|
||||
1 test-no-real-time-wait:src/main/antigravity/windows-hook-payload-delivery.test.ts
|
||||
@@ -184,7 +183,6 @@
|
||||
3 test-no-real-time-wait:src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx
|
||||
1 test-no-real-time-wait:src/renderer/src/components/terminal-pane/layout/split/resolve-split-cwd.test.ts
|
||||
2 test-no-real-time-wait:src/renderer/src/components/terminal-pane/pty-connection/emulation/fish-color-scheme-child-stdin.node-pty.test.ts
|
||||
1 test-no-real-time-wait:src/renderer/src/components/terminal-pane/viewport/terminal-fit-restore.test.ts
|
||||
1 test-no-real-time-wait:src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts
|
||||
2 test-no-real-time-wait:src/renderer/src/store/slices/workspace-cleanup/workspace-cleanup-removal-preflight.test.ts
|
||||
2 test-no-real-time-wait:src/shared/__tests__/process-host/windows-tree-kill-late-taskkill.win32.test.ts
|
||||
@@ -357,7 +355,6 @@
|
||||
2 test-no-wall-clock:src/main/agent-hooks/installation/commands/windows-native-hook-exe-hosts.test.ts
|
||||
2 test-no-wall-clock:src/main/agent-hooks/installation/scripts/managed-hook-timeout.test.ts
|
||||
1 test-no-wall-clock:src/main/agent-hooks/server/testing/http/server-endpoint-file-lifecycle.test.ts
|
||||
4 test-no-wall-clock:src/main/agent-hooks/server/testing/identity/server-launch-resume-identity.test.ts
|
||||
3 test-no-wall-clock:src/main/agent-hooks/server/testing/replay/spool.test.ts
|
||||
3 test-no-wall-clock:src/main/agent-hooks/server/testing/structured-ingress/server-ingest-structured-status.test.ts
|
||||
1 test-no-wall-clock:src/main/ai-vault/parse-cache/session-parse-cache-preview-upgrade.test.ts
|
||||
@@ -383,15 +380,11 @@
|
||||
1 test-no-wall-clock:src/main/daemon/transport/framing/testing/daemon-stream-binary-negotiation.test.ts
|
||||
2 test-no-wall-clock:src/main/external-conversations/external-conversation-cancel-settlement.test.ts
|
||||
3 test-no-wall-clock:src/main/external-conversations/external-conversation-cancel.test.ts
|
||||
2 test-no-wall-clock:src/main/git/command-runner/host-routing/git-command-resolution-windows-checkout.test.ts
|
||||
1 test-no-wall-clock:src/main/git/refs/maintenance/pack-refs-lock-ownership.test.ts
|
||||
2 test-no-wall-clock:src/main/git/refs/maintenance/repo-ref-maintenance-real-git.test.ts
|
||||
1 test-no-wall-clock:src/main/git/source-control/diffs/blob-absence-real-git.test.ts
|
||||
2 test-no-wall-clock:src/main/git/worktrees/catalog/worktree-created-description-real-git.test.ts
|
||||
3 test-no-wall-clock:src/main/github/pull-requests/refresh/pr-refresh-coordinator-active-visible-priority.test.ts
|
||||
8 test-no-wall-clock:src/main/github/pull-requests/refresh/pr-refresh-coordinator-alias-coalescing.test.ts
|
||||
3 test-no-wall-clock:src/main/github/pull-requests/refresh/pr-refresh-coordinator-completion-order.test.ts
|
||||
2 test-no-wall-clock:src/main/github/pull-requests/refresh/pr-refresh-queue-growth-bound.test.ts
|
||||
2 test-no-wall-clock:src/main/grok/grok-hook-stdin-no-eof.test.ts
|
||||
1 test-no-wall-clock:src/main/ipc/terminal-render-desync-evidence.test.ts
|
||||
2 test-no-wall-clock:src/main/ipc/worktrees-orphan-directory-cleanup.test.ts
|
||||
@@ -472,7 +465,6 @@
|
||||
2 test-no-wall-clock:src/main/wsl/execution/wsl-runner.wsl.test.ts
|
||||
3 test-no-wall-clock:src/main/wsl/orcad/wsl-orcad-real-distro-lane.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx
|
||||
2 test-no-wall-clock:src/renderer/src/components/artifacts/ArtifactCollection.test.tsx
|
||||
1 test-no-wall-clock:src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx
|
||||
1 test-no-wall-clock:src/renderer/src/components/sidebar/workspace-card/review/use-workspace-reference-details.test.tsx
|
||||
1 test-no-wall-clock:src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-agent-expansion-coupling.test.tsx
|
||||
|
||||
@@ -5,7 +5,6 @@ import original from '../mobile/vitest.config.ts'
|
||||
const nodeFiles = [
|
||||
'scripts/check-tests-typecheck-incremental-cache.test.ts',
|
||||
'src/mock-server-key-pair.test.ts',
|
||||
'src/mobile-release/prepare-android-release-script.test.ts',
|
||||
'src/navigation/query-string-named-exports.test.ts',
|
||||
'src/terminal/document/native-document-bundle.test.ts',
|
||||
'src/transport/encryption/mobile-e2ee-v2-client-session.test.ts',
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
+1
-88
@@ -5,8 +5,7 @@ import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { writeMobileWebBundleFixtureTree } from '../../build/mobile-web-app/mobile-web-bundle-fixture-tree.mjs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const REPO_ROOT = join(import.meta.dirname, '../../..', '..')
|
||||
|
||||
@@ -71,12 +70,6 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// All-negation lists implicitly include **/*; missing examples exclusion shipped hostile-panel in 1.4.160-rc.3. Exercise real matcher, not pattern spelling.
|
||||
|
||||
// CI never creates ~270MB/branch out/electron-dev caches; only local packages exposed this bloat.
|
||||
|
||||
// Duplicated relay.js added 14MB to asar and AV quarantine gutted the entire archive (#20966); packaged app uses resources/relay.
|
||||
|
||||
// Bundled agent-browser answered "No skills found" because only its binary shipped (#17705).
|
||||
const hostBuilderPlatform = { darwin: 'mac', linux: 'linux', win32: 'win' }[process.platform]
|
||||
it.runIf(hostBuilderPlatform)(
|
||||
@@ -114,84 +107,4 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// CLI Node mode cannot read asar; packed entry sends orca serve to Electron (#27307).
|
||||
|
||||
// Swept one-time /tmp asar extracts cause web-client 500s and phone bundle failures until restart (#27976).
|
||||
|
||||
it('validates each AppImage before electron-builder publishes it', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-appimage-'))
|
||||
try {
|
||||
const appImage = join(root, 'orca-linux.AppImage')
|
||||
await writeFile(appImage, 'not an ELF')
|
||||
await chmod(appImage, 0o755)
|
||||
|
||||
expect(() =>
|
||||
electronBuilderConfig.artifactBuildCompleted({ file: appImage, arch: 1 })
|
||||
).toThrow(/ELF header is outside/)
|
||||
expect(() =>
|
||||
electronBuilderConfig.artifactBuildCompleted({ file: join(root, 'orca-ide.deb') })
|
||||
).not.toThrow()
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('arch-aware packaging guard', () => {
|
||||
// electron-builder Arch enum: ia32=0, x64=1, armv7l=2, arm64=3.
|
||||
const HOST_ARCH = process.arch === 'arm64' ? 3 : 1
|
||||
|
||||
// Native tests need valid mobile bundle for beforePack; use temp build, leaving bundle verification to its own suite.
|
||||
let scratch
|
||||
let bundleDir
|
||||
beforeAll(async () => {
|
||||
scratch = await mkdtemp(join(tmpdir(), 'orca-electron-builder-guard-'))
|
||||
bundleDir = join(scratch, 'mobile-web')
|
||||
await writeMobileWebBundleFixtureTree({ outDir: bundleDir })
|
||||
})
|
||||
afterAll(async () => {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const packHost = (arch) =>
|
||||
electronBuilderConfig.beforePack(
|
||||
{ electronPlatformName: process.platform, arch },
|
||||
bundleDir,
|
||||
() => undefined
|
||||
)
|
||||
|
||||
it('refuses to package a dark layout-owner build', () => {
|
||||
const previous = process.env.ORCA_LAYOUT_OWNER
|
||||
process.env.ORCA_LAYOUT_OWNER = 'main'
|
||||
try {
|
||||
expect(() => packHost(HOST_ARCH)).toThrow('ORCA_LAYOUT_OWNER=main')
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.ORCA_LAYOUT_OWNER
|
||||
} else {
|
||||
process.env.ORCA_LAYOUT_OWNER = previous
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to package an orcad template marked by a dark test build', async () => {
|
||||
const templateDir = join(scratch, 'orcad-template')
|
||||
await mkdir(templateDir, { recursive: true })
|
||||
await writeFile(join(templateDir, 'ORCA_LAYOUT_OWNER_MAIN'), 'dark\n')
|
||||
try {
|
||||
expect(() =>
|
||||
electronBuilderConfig.beforePack(
|
||||
{ electronPlatformName: process.platform, arch: HOST_ARCH },
|
||||
bundleDir,
|
||||
() => undefined,
|
||||
templateDir
|
||||
)
|
||||
).toThrow(`${templateDir} holds an ORCA_LAYOUT_OWNER=main build`)
|
||||
} finally {
|
||||
await rm(templateDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// The host's own build is the foreign-arch case: a Windows checkout compiles these for its CPU only.
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@ const draft = { id: 259, draft: true, tag_name: tag, body: 'original notes' }
|
||||
|
||||
function fixture(options = {}) {
|
||||
const writes = []
|
||||
const reads = []
|
||||
let wrote = false
|
||||
const response = (body, status = 200) => ({
|
||||
ok: status === 200,
|
||||
@@ -27,7 +26,6 @@ function fixture(options = {}) {
|
||||
const fetchImpl = async (url, request = {}) => {
|
||||
const route = url.replace('https://api.github.com/repos/test/repo', '')
|
||||
const method = request.method ?? 'GET'
|
||||
reads.push(route)
|
||||
if (route === `/git/ref/tags/${tag}`) {
|
||||
if (options.missing || (wrote && options.deleted)) {
|
||||
return response({}, 404)
|
||||
@@ -72,7 +70,6 @@ function fixture(options = {}) {
|
||||
}
|
||||
return {
|
||||
writes,
|
||||
reads,
|
||||
run: () =>
|
||||
createDraftRelease({
|
||||
repo: 'test/repo',
|
||||
@@ -114,14 +111,6 @@ describe('release draft source and uploader contract', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('peels the captured tag object ID instead of re-dereferencing the tag name', async () => {
|
||||
const f = fixture({ annotated: true })
|
||||
await f.run()
|
||||
expect(f.reads).toContain(`/git/tags/${tagObject}`)
|
||||
expect(f.reads).toContain(`/git/commits/${checkout}`)
|
||||
expect(f.reads.some((route) => route.startsWith('/commits/'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ missing: true },
|
||||
{ annotated: true, commit: 'e'.repeat(40) },
|
||||
@@ -187,31 +176,10 @@ describe('release draft source and uploader contract', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8'))
|
||||
|
||||
describe('release-cut draft wiring', () => {
|
||||
it('passes the approved source and only the job token to the helper', () => {
|
||||
const job = workflow.jobs['create-release']
|
||||
const step = job.steps.find((s) => s.id === 'draft')
|
||||
expect(step.env).toMatchObject({
|
||||
SOURCE_SHA: '${{ needs.cut.outputs.source_sha }}',
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
})
|
||||
expect(job.outputs.tag_object).toBe('${{ steps.draft.outputs.tag_object }}')
|
||||
})
|
||||
|
||||
it('isolates both real-process skill release suites', () => {
|
||||
for (const name of ['skill-sharing-release-gate', 'skill-sharing-linux-floor-release-gate']) {
|
||||
const step = workflow.jobs[name].steps.find((s) => s.name?.startsWith('Run skill package'))
|
||||
expect(step.env.ORCA_VITEST_ISOLATE_EVERY_FILE).toBe('1')
|
||||
}
|
||||
})
|
||||
|
||||
const steps = workflow.jobs['publish-release'].steps
|
||||
const guardIndex = steps.findIndex(
|
||||
(s) => s.name === 'Verify release tag still matches the drafted source'
|
||||
)
|
||||
it('re-checks the tag immediately before undrafting', () => {
|
||||
expect(steps[guardIndex + 1].name).toBe('Publish release')
|
||||
expect(workflow.jobs['publish-release'].needs).toContain('create-release')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32').each([
|
||||
['lightweight tag unchanged', { type: 'commit', sha: checkout }, checkout, 0],
|
||||
|
||||
@@ -110,14 +110,11 @@ const NODE_RUNTIME_INCLUDE = [
|
||||
'src/main/pty/process-retirement/posix-pty-process-groups.integration.test.ts',
|
||||
'src/shared/remote-runtime/ephemeral-vm-recipes/ephemeral-vm-recipe-output-capture.test.ts',
|
||||
'src/main/native-chat/agent-session-journal/**/*.test.ts',
|
||||
'src/main/runtime/agent-session/records/agent-session-conversation-name-store.test.ts',
|
||||
'src/main/runtime/rpc/methods/structured-sessions/attachments/structured-agent-session-visual.test.ts',
|
||||
// Bun resolves 'ws' to its built-in client, which bypasses the patched package.
|
||||
'src/main/runtime/rpc/transports/websocket/ws-empty-payload-frame-write.test.ts',
|
||||
'src/shared/remote-runtime/remote-runtime-client.test.ts',
|
||||
'src/shared/remote-runtime/remote-runtime-connect-bound.test.ts',
|
||||
// Bun's built-in ws client ignores maxPayload; the product opener only runs on Node.
|
||||
'src/shared/remote-runtime/requests/remote-runtime-request-websocket.test.ts',
|
||||
'src/shared/remote-runtime/requests/remote-runtime-subscription-connect-bound.test.ts',
|
||||
'src/main/codex/configuration/shared-server/codex-shared-server-probe.test.ts',
|
||||
'src/main/opencode/model-launch/opencode-launch-model-context-supervision.test.ts',
|
||||
|
||||
@@ -166,7 +166,6 @@ export const WINDOWS_BOUNDARY_TESTS = [
|
||||
'src/shared/agent-launch/startup/omp-fresh-launch-cmd.win32.test.ts',
|
||||
'src/shared/windows-batch-spawn.win32.test.ts',
|
||||
'src/main/claude/structured/launch/claude-structured-child-env.test.ts',
|
||||
'src/main/agent-hooks/installation/commands/windows-hook-payload-delivery.test.ts',
|
||||
'src/main/jcode/hook-gate-script.test.ts',
|
||||
'src/main/agent-hooks/installation/commands/windows-native-hook-exe-hosts.test.ts',
|
||||
'src/main/agent-hooks/installation/commands/windows-batch-hook-launcher.test.ts',
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { createElement, useState } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { MountedBottomDrawer } from './mounted-bottom-drawer'
|
||||
import { useBottomDrawerFollowUp } from './use-bottom-drawer-follow-up'
|
||||
|
||||
// BottomDrawer also carries the option pager, which loads these at import.
|
||||
vi.mock('react-native', () => ({ StyleSheet: { create: <T,>(sheet: T) => sheet }, View: 'View' }))
|
||||
vi.mock('react-native-reanimated', async () =>
|
||||
(await import('../../test-support/reanimated-pager-stub')).reanimatedPagerStub()
|
||||
)
|
||||
vi.mock('./mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'MountedBottomDrawer' }))
|
||||
|
||||
describe('drawer handoff', () => {
|
||||
let renderer: ReactTestRenderer | undefined
|
||||
let handoff: ReturnType<typeof useBottomDrawerFollowUp> | undefined
|
||||
function Harness() {
|
||||
const [visible, setVisible] = useState(true)
|
||||
handoff = useBottomDrawerFollowUp(() => setVisible(false))
|
||||
return (
|
||||
<BottomDrawer visible={visible} {...handoff.drawerProps}>
|
||||
Source
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
function mount() {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
}
|
||||
afterEach(() => act(() => renderer?.unmount()))
|
||||
|
||||
it('opens the next surface once, after the source drawer has unmounted', () => {
|
||||
mount()
|
||||
const next = vi.fn(() => expect(renderer?.toJSON()).toBeNull())
|
||||
act(() => handoff?.closeThen(next))
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
expect(renderer?.toJSON()).not.toBeNull()
|
||||
const onHidden = renderer?.root.findByType(MountedBottomDrawer).props.onHidden
|
||||
act(() => onHidden())
|
||||
act(() => onHidden())
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops queued work when its screen unmounts', () => {
|
||||
mount()
|
||||
const next = vi.fn()
|
||||
const afterClose = handoff?.drawerProps.onAfterClose
|
||||
act(() => handoff?.closeThen(next))
|
||||
act(() => renderer?.unmount())
|
||||
act(() => afterClose?.())
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { boundConnectionDiagnosticsReport } from './connection-diagnostics-submission'
|
||||
|
||||
describe('submitConnectionDiagnostics', () => {
|
||||
it('preserves the newest complete events when bounding a UTF-8 report', () => {
|
||||
const report = [
|
||||
'Orca Mobile connection diagnostics',
|
||||
'State: reconnecting',
|
||||
'',
|
||||
'Recent connection history (3 events, oldest first):',
|
||||
`oldest ${'😀'.repeat(20)}`,
|
||||
`middle ${'😀'.repeat(20)}`,
|
||||
`newest ${'😀'.repeat(20)}`
|
||||
].join('\n')
|
||||
|
||||
const bounded = boundConnectionDiagnosticsReport(report, 180)
|
||||
|
||||
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(180)
|
||||
expect(bounded).toContain('newest')
|
||||
expect(bounded).not.toContain('oldest')
|
||||
expect(bounded).toMatch(/older omitted/)
|
||||
})
|
||||
|
||||
it('retains a bounded form of the newest event when that event exceeds the budget', () => {
|
||||
const report = [
|
||||
'Orca Mobile connection diagnostics',
|
||||
'State: reconnecting',
|
||||
'',
|
||||
'Recent connection history (2 events, oldest first):',
|
||||
'old event',
|
||||
`newest oversized ${'😀'.repeat(1_000)}`
|
||||
].join('\n')
|
||||
|
||||
const bounded = boundConnectionDiagnosticsReport(report, 220)
|
||||
|
||||
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(220)
|
||||
expect(bounded).toContain('newest oversized')
|
||||
expect(bounded).toContain('[truncated]')
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AccountProviderRateLimits, AccountsSnapshot } from '../../accounts/accounts-snapshot'
|
||||
import { selectHomeUsageCards } from './home-usage-cards'
|
||||
|
||||
function limits(
|
||||
provider: 'claude' | 'codex',
|
||||
usedPercent: number,
|
||||
overrides: Partial<AccountProviderRateLimits> = {}
|
||||
): AccountProviderRateLimits {
|
||||
return {
|
||||
provider,
|
||||
session: { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null },
|
||||
weekly: null,
|
||||
updatedAt: 1_000,
|
||||
error: null,
|
||||
status: 'ok',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(overrides: {
|
||||
claude?: Partial<AccountsSnapshot['claude']>
|
||||
codex?: Partial<AccountsSnapshot['codex']>
|
||||
rateLimits?: Partial<AccountsSnapshot['rateLimits']>
|
||||
}): AccountsSnapshot {
|
||||
return {
|
||||
claude: { accounts: [], activeAccountId: null, ...overrides.claude },
|
||||
codex: { accounts: [], activeAccountId: null, ...overrides.codex },
|
||||
rateLimits: {
|
||||
claude: null,
|
||||
codex: null,
|
||||
claudeTarget: { runtime: 'host', wslDistro: null },
|
||||
codexTarget: { runtime: 'host', wslDistro: null },
|
||||
inactiveClaudeAccounts: [],
|
||||
inactiveCodexAccounts: [],
|
||||
...overrides.rateLimits
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('selectHomeUsageCards', () => {
|
||||
it('lists the active managed account first, with no System default, and inactive usage from its own fetch', () => {
|
||||
const cards = selectHomeUsageCards(
|
||||
snapshot({
|
||||
claude: {
|
||||
accounts: [
|
||||
{ id: 'a', email: 'a@example.com', organizationName: 'Acme' },
|
||||
{ id: 'b', email: 'b@example.com' }
|
||||
],
|
||||
activeAccountId: 'b'
|
||||
},
|
||||
rateLimits: {
|
||||
claude: limits('claude', 70),
|
||||
inactiveClaudeAccounts: [
|
||||
{ accountId: 'a', rateLimits: limits('claude', 10), updatedAt: 1, isFetching: true }
|
||||
]
|
||||
}
|
||||
}),
|
||||
'claude'
|
||||
)
|
||||
expect(
|
||||
cards.map((card) => [
|
||||
card.email,
|
||||
card.subtitle,
|
||||
card.active,
|
||||
card.limits?.session?.usedPercent,
|
||||
card.fetching
|
||||
])
|
||||
).toEqual([
|
||||
['b@example.com', null, true, 70, false],
|
||||
['a@example.com', 'Acme', false, 10, true]
|
||||
])
|
||||
})
|
||||
|
||||
it('reads the Codex active account for the rate-limit runtime target', () => {
|
||||
const cards = selectHomeUsageCards(
|
||||
snapshot({
|
||||
codex: {
|
||||
accounts: [
|
||||
{ id: 'host', email: 'host@example.com', updatedAt: 1, workspaceLabel: 'Team' },
|
||||
{ id: 'wsl', email: 'wsl@example.com', updatedAt: 1 }
|
||||
],
|
||||
activeAccountId: 'host',
|
||||
activeAccountIdsByRuntime: { host: 'host', wsl: { Ubuntu: 'wsl' } }
|
||||
},
|
||||
rateLimits: { codexTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' } }
|
||||
}),
|
||||
'codex'
|
||||
)
|
||||
expect(cards.map((card) => [card.accountId, card.subtitle, card.active])).toEqual([
|
||||
['wsl', null, true],
|
||||
['host', 'Team', false]
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useActiveWorktreeScroll } from './use-active-worktree-scroll'
|
||||
|
||||
type Row = { row: { worktreeId: string; isActive?: boolean } }
|
||||
type Sections = { key: string; data: Row[] }[]
|
||||
|
||||
const listOf = (active: string | null, folded = false): Sections => [
|
||||
{
|
||||
key: 'all',
|
||||
data: folded
|
||||
? []
|
||||
: ['a', 'b', 'c'].map((worktreeId) => ({
|
||||
row: { worktreeId, isActive: worktreeId === active }
|
||||
}))
|
||||
}
|
||||
]
|
||||
|
||||
const NONE = new Set<string>()
|
||||
const FOLDED = new Set(['all'])
|
||||
|
||||
function mount(sections: Sections, collapsedGroups: ReadonlySet<string> = NONE) {
|
||||
const scrollToLocation = vi.fn()
|
||||
function Probe(props: { sections: Sections; collapsedGroups: ReadonlySet<string> }): null {
|
||||
const { sectionListRef } = useActiveWorktreeScroll(props.sections, props.collapsedGroups)
|
||||
Object.assign(sectionListRef, { current: { scrollToLocation } })
|
||||
return null
|
||||
}
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { sections, collapsedGroups }))
|
||||
})
|
||||
const update = (next: Sections, nextCollapsed: ReadonlySet<string> = NONE) =>
|
||||
act(() => {
|
||||
renderer?.update(createElement(Probe, { sections: next, collapsedGroups: nextCollapsed }))
|
||||
})
|
||||
return { scrollToLocation, update }
|
||||
}
|
||||
|
||||
describe('useActiveWorktreeScroll', () => {
|
||||
it('scrolls to the highlighted row, and again when another workspace is highlighted', () => {
|
||||
const list = mount(listOf('b'))
|
||||
expect(list.scrollToLocation).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sectionIndex: 0, itemIndex: 1 })
|
||||
)
|
||||
list.update(listOf('c'))
|
||||
expect(list.scrollToLocation).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ itemIndex: 2 })
|
||||
)
|
||||
expect(list.scrollToLocation).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('never scrolls when a fold or search hides the row and it comes back', () => {
|
||||
const list = mount(listOf('b'))
|
||||
list.update(listOf('b', true), FOLDED)
|
||||
list.update(listOf('b'), NONE)
|
||||
list.update(listOf(null))
|
||||
list.update(listOf('b'))
|
||||
expect(list.scrollToLocation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never scrolls to a highlight an unfold first reveals', () => {
|
||||
const list = mount(listOf('b', true), FOLDED)
|
||||
list.update(listOf('b'), NONE)
|
||||
expect(list.scrollToLocation).not.toHaveBeenCalled()
|
||||
list.update(listOf('c'), NONE)
|
||||
expect(list.scrollToLocation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const scriptPath = fileURLToPath(
|
||||
new URL('../../scripts/prepare-android-release.mjs', import.meta.url)
|
||||
)
|
||||
|
||||
const appConfig = {
|
||||
expo: {
|
||||
version: '0.0.22',
|
||||
android: {
|
||||
versionCode: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tempDirs: string[] = []
|
||||
|
||||
function createAppConfig() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-android-release-'))
|
||||
tempDirs.push(dir)
|
||||
const configPath = join(dir, 'app.json')
|
||||
const contents = `${JSON.stringify(appConfig, null, 2)}\n`
|
||||
writeFileSync(configPath, contents)
|
||||
const outputPath = join(dir, 'github-output')
|
||||
writeFileSync(outputPath, '')
|
||||
return { configPath, contents, outputPath }
|
||||
}
|
||||
|
||||
function scenarioEnv(configPath: string, outputPath: string, inputs: Record<string, string>) {
|
||||
const env = { ...process.env }
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith('MOBILE_ANDROID_') || key === 'GITHUB_REF' || key === 'GITHUB_OUTPUT') {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
return { ...env, MOBILE_APP_CONFIG_PATH: configPath, GITHUB_OUTPUT: outputPath, ...inputs }
|
||||
}
|
||||
|
||||
describe('prepare Android release script', () => {
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { force: true, recursive: true })
|
||||
}
|
||||
tempDirs = []
|
||||
})
|
||||
|
||||
it('rejects release-only Android versionCode bumps', () => {
|
||||
const { configPath, contents, outputPath } = createAppConfig()
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: scenarioEnv(configPath, outputPath, { MOBILE_ANDROID_BUMP_VERSION_CODE: 'true' })
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(readFileSync(configPath, 'utf8')).toBe(contents)
|
||||
expect(readFileSync(outputPath, 'utf8')).toBe('')
|
||||
expect(result.stderr).toContain(
|
||||
'Android versionCode changes must be committed in mobile/app.json before release'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects release versions that do not match committed app config', () => {
|
||||
const { configPath, contents, outputPath } = createAppConfig()
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: scenarioEnv(configPath, outputPath, { MOBILE_ANDROID_RELEASE_VERSION: '0.0.23' })
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(readFileSync(configPath, 'utf8')).toBe(contents)
|
||||
expect(readFileSync(outputPath, 'utf8')).toBe('')
|
||||
expect(result.stderr).toContain(
|
||||
'MOBILE_ANDROID_RELEASE_VERSION must match the committed mobile app version'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,146 +0,0 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { removeTreeSync } from '../../../../shared/windows-transient-lock-removal'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import type * as osModule from 'node:os'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-user-data'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof osModule>()
|
||||
return {
|
||||
...actual,
|
||||
homedir: homedirMock.mockImplementation(actual.homedir)
|
||||
}
|
||||
})
|
||||
import { CopilotHookService } from '../../../copilot/hook-service'
|
||||
import {
|
||||
WINDOWS_POWERSHELL_WARM_UP_TIMEOUT_MS,
|
||||
warmWindowsEncodedPowerShell
|
||||
} from './testing/windows-powershell-warm-up.test-fixture'
|
||||
// Multiple chunks and non-ASCII expose pipe truncation and code-page conversion.
|
||||
const PAYLOAD = JSON.stringify({
|
||||
session_id: 'a3f9c1e0-payload-delivery',
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'echo "café 日本語 😀" & echo %PATH% | more' },
|
||||
transcript: 'y'.repeat(4096)
|
||||
})
|
||||
|
||||
type Runner = {
|
||||
name: string
|
||||
program: () => string
|
||||
args: (command: string) => string[]
|
||||
verbatim?: boolean
|
||||
}
|
||||
|
||||
const SYSTEM32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')
|
||||
const CMD = join(SYSTEM32, 'cmd.exe')
|
||||
const POWERSHELL = join(SYSTEM32, 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
||||
function findPwsh(): string | undefined {
|
||||
const candidates = [
|
||||
join(process.env.ProgramFiles ?? 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
|
||||
join(process.env.LOCALAPPDATA ?? '', 'Microsoft', 'WindowsApps', 'pwsh.exe')
|
||||
]
|
||||
// lstat: the Store install is an app-execution alias that existsSync cannot follow.
|
||||
return candidates.find((candidate) => lstatSync(candidate, { throwIfNoEntry: false }))
|
||||
}
|
||||
// Copilot's `<pwsh|powershell> -nop -nol -c <command>`; pwsh only where it is installed.
|
||||
const COPILOT_RUNNERS: Runner[] = [POWERSHELL, findPwsh()].flatMap((program) =>
|
||||
program
|
||||
? [
|
||||
{
|
||||
name: `${basename(program)} (Copilot)`,
|
||||
program: () => program,
|
||||
args: (c: string) => ['-nop', '-nol', '-c', c]
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
|
||||
type Entry = { command?: string; powershell?: string; hooks?: { command?: string }[] }
|
||||
const commandsOf = (entries: Entry[] | undefined): (string | undefined)[] =>
|
||||
(entries ?? []).flatMap((entry) =>
|
||||
entry.hooks ? entry.hooks.map((h) => h.command) : (entry.command ?? entry.powershell)
|
||||
)
|
||||
|
||||
function readEvent(path: string, event: string): (string | undefined)[] {
|
||||
return commandsOf(JSON.parse(readFileSync(path, 'utf8')).hooks?.[event])
|
||||
}
|
||||
|
||||
// Inherited Orca endpoints must not redirect test payloads away from this listener.
|
||||
function hookEnvironment(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const base = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('ORCA_'))
|
||||
)
|
||||
return { ...base, ORCA_BACKGROUND_LAUNCH: '1', ...extra }
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform !== 'win32')('Windows managed hook payload delivery', () => {
|
||||
let root = ''
|
||||
|
||||
beforeAll(
|
||||
() => warmWindowsEncodedPowerShell(hookEnvironment({})),
|
||||
WINDOWS_POWERSHELL_WARM_UP_TIMEOUT_MS
|
||||
)
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
if (root) {
|
||||
removeTreeSync(root)
|
||||
root = ''
|
||||
}
|
||||
})
|
||||
|
||||
// The control verifies profile-based AutoRun ran; printing it must not corrupt Copilot gate JSON.
|
||||
it('Copilot answers exactly {} when the cmd AutoRun prints', async () => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-hook-autorun-'))
|
||||
const home = join(root, 'user')
|
||||
mkdirSync(home)
|
||||
homedirMock.mockReturnValue(home)
|
||||
for (const name of ['USERPROFILE', 'HOME']) {
|
||||
vi.stubEnv(name, home)
|
||||
}
|
||||
vi.stubEnv('COPILOT_HOME', undefined)
|
||||
writeFileSync(join(home, '.cmd_aliases.cmd'), '@echo autorun-noise\r\n', 'utf8')
|
||||
expect(new CopilotHookService().install().state).toBe('installed')
|
||||
const [command] = readEvent(join(home, '.copilot', 'hooks', 'orca.json'), 'PreToolUse')
|
||||
if (!command) {
|
||||
throw new Error('Missing managed Copilot PreToolUse command')
|
||||
}
|
||||
const env = hookEnvironment({ USERPROFILE: home, HOME: home })
|
||||
|
||||
const autoRun = await runProcess({ program: CMD, args: ['/c', 'rem'], env, timeoutMs: 15_000 })
|
||||
for (const runner of COPILOT_RUNNERS) {
|
||||
const result = await runProcess({
|
||||
program: runner.program(),
|
||||
args: runner.args(command),
|
||||
env,
|
||||
input: PAYLOAD,
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(result.code, `${runner.name} exit code`).toBe(0)
|
||||
expect(result.stdout, `${runner.name} stdout`).toBe('{}\r\n')
|
||||
}
|
||||
if (autoRun.stdout.includes('autorun-noise')) {
|
||||
const bare = command.replace(/^\S+\/cmd\.exe \/d \/c /, '')
|
||||
const control = await runProcess({
|
||||
program: POWERSHELL,
|
||||
args: ['-nop', '-nol', '-c', bare],
|
||||
env,
|
||||
input: PAYLOAD,
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(control.stdout, 'without /d the AutoRun prints first').toContain('autorun-noise')
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedAgentHookTarget } from '../../../shared/managed-agent-hook-targets'
|
||||
import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
|
||||
|
||||
const { mergePersistedWindowsPathAsyncMock } = vi.hoisted(() => ({
|
||||
mergePersistedWindowsPathAsyncMock: vi.fn(async (_env: NodeJS.ProcessEnv) => undefined)
|
||||
}))
|
||||
vi.mock('../../pty/environment/windows-environment-path', () => ({
|
||||
mergePersistedWindowsPathAsync: mergePersistedWindowsPathAsyncMock
|
||||
}))
|
||||
|
||||
const codexTarget: ManagedAgentHookTarget = {
|
||||
agent: 'codex',
|
||||
tuiAgent: 'codex',
|
||||
executableCandidates: ['codex']
|
||||
}
|
||||
|
||||
const claudeTarget: ManagedAgentHookTarget = {
|
||||
agent: 'claude',
|
||||
tuiAgent: 'claude',
|
||||
executableCandidates: ['claude']
|
||||
}
|
||||
|
||||
describe('detectLocalManagedAgentCliPresence', () => {
|
||||
let tmpDir: string | null = null
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
tmpDir = null
|
||||
}
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'accepts executable symlinks and rejects broken symlinks',
|
||||
async () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cli-presence-'))
|
||||
const binDir = join(tmpDir, 'bin')
|
||||
mkdirSync(binDir)
|
||||
const targetPath = join(tmpDir, 'codex-real')
|
||||
writeFileSync(targetPath, '#!/bin/sh\n')
|
||||
chmodSync(targetPath, 0o755)
|
||||
symlinkSync(targetPath, join(binDir, 'codex'))
|
||||
symlinkSync(join(tmpDir, 'missing'), join(binDir, 'claude'))
|
||||
|
||||
const result = await detectLocalManagedAgentCliPresence(
|
||||
[codexTarget, claudeTarget],
|
||||
{ agentCmdOverrides: {} },
|
||||
{ pathEnv: binDir, pathDelimiter: ':', platform: process.platform }
|
||||
)
|
||||
|
||||
expect(result.codex?.state).toBe('found')
|
||||
expect(result.claude?.state).toBe('missing')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/userData' } }))
|
||||
|
||||
import { getManagedScript as getClaudeManagedScript } from '../../../claude/hooks/hook-script'
|
||||
import { getManagedScript as getCursorManagedScript } from '../../../cursor/hook-script'
|
||||
import { runVendorHook } from './testing/vendor-hook-delivery.test-fixture'
|
||||
|
||||
const CLAUDE_SCRIPT_OPTIONS = {
|
||||
skipWhenDevinImportsClaude: true,
|
||||
skipWhenGrokImportsClaude: true
|
||||
}
|
||||
|
||||
describe('Grok vendor hook replay guard', () => {
|
||||
it('drops Grok-replayed hooks without suppressing their protocol response', async () => {
|
||||
for (const script of [
|
||||
getClaudeManagedScript('local', CLAUDE_SCRIPT_OPTIONS),
|
||||
getCursorManagedScript('local')
|
||||
]) {
|
||||
expect((await runVendorHook(script, { GROK_HOOK_EVENT: 'Stop' })).requests).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'keeps Grok-replayed hooks out of the spool',
|
||||
async () => {
|
||||
for (const [source, script] of [
|
||||
['claude', getClaudeManagedScript('posix', CLAUDE_SCRIPT_OPTIONS)],
|
||||
['cursor', getCursorManagedScript('posix')]
|
||||
]) {
|
||||
const ordinary = await runVendorHook(script, { GROK_HOOK_EVENT: '' }, 'spool')
|
||||
expect(ordinary.requests).toEqual([])
|
||||
expect(ordinary.spooled).toHaveLength(1)
|
||||
expect(JSON.parse(ordinary.spooled[0])).toMatchObject({
|
||||
source,
|
||||
paneKey: 'tab:leaf',
|
||||
payload: { hook_event_name: 'Stop' }
|
||||
})
|
||||
const replayed = await runVendorHook(script, { GROK_HOOK_EVENT: 'Stop' }, 'spool')
|
||||
expect(replayed.requests).toEqual([])
|
||||
expect(replayed.spooled).toEqual([])
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,157 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as CodexHookHashLookup from '../../../codex/trust/hooks/codex-hook-hash-lookup'
|
||||
import {
|
||||
existsSync,
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import type * as osModule from 'node:os'
|
||||
|
||||
let isolatedUserDataDir = ''
|
||||
let previousUserDataPath: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('GROK_HOME', undefined)
|
||||
vi.stubEnv('KIMI_CODE_HOME', undefined)
|
||||
vi.stubEnv('COPILOT_HOME', undefined)
|
||||
vi.stubEnv('KIRO_HOME', undefined)
|
||||
vi.stubEnv('XDG_CONFIG_HOME', undefined)
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-user-data-'))
|
||||
process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
rmSync(isolatedUserDataDir, { recursive: true, force: true })
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('../../../codex/trust/hooks/codex-hook-hash-lookup', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexHookHashLookup>()),
|
||||
resolveCodexHookAnswerForLaunch: async () =>
|
||||
(
|
||||
await import('../../../codex/hooks/installation/hook-service-test-harness')
|
||||
).codexHookAnswerForTests()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-user-data'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof osModule>()
|
||||
return {
|
||||
...actual,
|
||||
homedir: homedirMock.mockImplementation(actual.homedir)
|
||||
}
|
||||
})
|
||||
|
||||
import { refreshManagedScriptIfPresent } from './managed-hook-script-refresh'
|
||||
import { ClaudeHookService } from '../../../claude/hook-service'
|
||||
|
||||
describe('refreshManagedScriptIfPresent', () => {
|
||||
it('rewrites an existing script and refuses to create a missing one', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-unit-'))
|
||||
try {
|
||||
const present = join(dir, 'present.cmd')
|
||||
writeFileSync(present, 'stale')
|
||||
expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true)
|
||||
expect(readFileSync(present, 'utf8')).toBe('fresh')
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(present, 0o600)
|
||||
}
|
||||
const fixedTime = new Date(1_000)
|
||||
utimesSync(present, fixedTime, fixedTime)
|
||||
expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true)
|
||||
expect(statSync(present).mtimeMs).toBe(fixedTime.getTime())
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(present).mode & 0o777).toBe(0o755)
|
||||
}
|
||||
|
||||
const missing = join(dir, 'missing.cmd')
|
||||
expect(await refreshManagedScriptIfPresent(missing, 'fresh')).toBe(false)
|
||||
expect(existsSync(missing)).toBe(false)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('managed hook script refresh', () => {
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'refreshes existing POSIX hook and statusline locale handling without a CLI config',
|
||||
async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-locale-refresh-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
try {
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
mkdirSync(hooksDir, { recursive: true })
|
||||
for (const name of ['claude-hook.sh', 'claude-statusline.sh']) {
|
||||
writeFileSync(join(hooksDir, name), '#!/bin/sh\ncurl --connect-timeout 0.5\n')
|
||||
}
|
||||
await new ClaudeHookService().refreshManagedScripts()
|
||||
for (const name of ['claude-hook.sh', 'claude-statusline.sh']) {
|
||||
const observed = join(home, `${name}.locale`)
|
||||
const result = spawnSync(
|
||||
'/bin/sh',
|
||||
[
|
||||
'-c',
|
||||
'curl() { printf "%s|%s" "$LC_ALL" "$LC_NUMERIC" > "$ORCA_TEST_CURL_LOCALE"; command -p cat >/dev/null; }; . "$1"',
|
||||
'hook',
|
||||
join(hooksDir, name)
|
||||
],
|
||||
{
|
||||
input: '{"hook_event_name":"Stop","rate_limits":{},"total_duration_ms":1000}',
|
||||
encoding: 'utf8',
|
||||
timeout: 5_000,
|
||||
env: {
|
||||
...process.env,
|
||||
LC_ALL: 'de_DE.UTF-8',
|
||||
LC_NUMERIC: 'de_DE.UTF-8',
|
||||
TMPDIR: home,
|
||||
CLAUDE_JOB_DIR: '',
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_AGENT_HOOK_PORT: '1',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'test-token',
|
||||
ORCA_PANE_KEY: 'pane-1',
|
||||
ORCA_TEST_CURL_LOCALE: observed
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(result.error, name).toBeUndefined()
|
||||
expect(result.status, name).toBe(0)
|
||||
expect(existsSync(observed), `${name} delivered to curl`).toBe(true)
|
||||
expect(readFileSync(observed, 'utf8'), name).toBe('|C')
|
||||
expect(statSync(join(hooksDir, name)).mode & 0o777).toBe(0o755)
|
||||
}
|
||||
expect(existsSync(join(home, '.claude'))).toBe(false)
|
||||
expect(readdirSync(hooksDir).sort()).toEqual(['claude-hook.sh', 'claude-statusline.sh'])
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createLocalPtyInventoryReader,
|
||||
createLocalTmuxManagedPtyResolver
|
||||
} from './local-tmux-managed-pty'
|
||||
import type { PtyProcessInfo } from '../../providers/pty/process-info'
|
||||
|
||||
const row: PtyProcessInfo = {
|
||||
id: 'pty',
|
||||
incarnationId: 'generation',
|
||||
rootProcessId: 123,
|
||||
cwd: '/workspace',
|
||||
title: 'shell',
|
||||
worktreeId: 'folder:workspace'
|
||||
}
|
||||
describe.skipIf(process.platform === 'win32')('authenticated local tmux root resolution', () => {
|
||||
it('refuses a binding changed during capture and excludes WSL roots from a native probe', async () => {
|
||||
let binding = 'pty'
|
||||
const resolve = createLocalTmuxManagedPtyResolver(
|
||||
createLocalPtyInventoryReader({
|
||||
getPtyId: () => binding,
|
||||
listProcesses: async () => {
|
||||
binding = 'replacement'
|
||||
return [row]
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(await resolve('pane')).toBeNull()
|
||||
const wsl = createLocalTmuxManagedPtyResolver(
|
||||
createLocalPtyInventoryReader({
|
||||
getPtyId: () => 'pty',
|
||||
listProcesses: async () => [{ ...row, wslDistro: 'Ubuntu-24.04' }]
|
||||
})
|
||||
)
|
||||
expect(await wsl('pane')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer } from '../../agent-hook-server'
|
||||
import { PANE } from '../server.test-fixtures'
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const working = { agentType: 'omp', state: 'working', prompt: 'new turn' }
|
||||
|
||||
describe('OMP retired-pane remote ingress', () => {
|
||||
it('recovers a legacy source-less OMP new turn using validated payload identity', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.retirePaneAuthority(PANE)
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, hookEventName: 'before_agent_start', payload: working },
|
||||
'ssh'
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
observation: expect.objectContaining({ boundary: true })
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ payload: { ...working, state: 'invalid' } },
|
||||
{ payload: { ...working, agentType: 'claude' } },
|
||||
{ isReplay: 'true' },
|
||||
{ isReplay: null },
|
||||
{ launchToken: 42 },
|
||||
{ providerSessionOnly: true }
|
||||
])('rejected metadata leaves retirement intact: %j', (invalid) => {
|
||||
const server = new AgentHookServer()
|
||||
server.retirePaneAuthority(PANE)
|
||||
const input = {
|
||||
paneKey: PANE,
|
||||
source: 'omp',
|
||||
hookEventName: 'before_agent_start',
|
||||
payload: working,
|
||||
...invalid
|
||||
}
|
||||
// Exercise raw JSON ingress, including malformed fields a typed caller cannot create.
|
||||
server.ingestRemote(JSON.parse(JSON.stringify(input)), 'ssh')
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
source: 'omp',
|
||||
hookEventName: 'agent_end',
|
||||
payload: { ...working, state: 'done' }
|
||||
},
|
||||
'ssh'
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, source: 'omp', hookEventName: 'before_agent_start', payload: working },
|
||||
'ssh'
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, state: 'working' })
|
||||
])
|
||||
})
|
||||
|
||||
it('a replay cannot restore a retired OMP pane', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.retirePaneAuthority(PANE)
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, hookEventName: 'before_agent_start', isReplay: true, payload: working },
|
||||
'ssh'
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, source: 'omp', hookEventName: 'before_agent_start', payload: working },
|
||||
'ssh'
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, state: 'working' })
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,159 +0,0 @@
|
||||
import { readSavedStatus, hasSavedStatus } from '../persistence/last-status.test-fixtures'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { buildBody, GOOD_PANE, postHookEvent, PANE } from '../server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({
|
||||
track: trackMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: getCohortAtEmitMock
|
||||
}))
|
||||
|
||||
const REMINTED = '$$MFRGGZDFMY:L$$'
|
||||
const FOREIGN = '$$ONXW2ZJAON:L$$'
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('reminted $$ pane keys on the OMP hook pipeline', () => {
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-reminted-pane-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function lastStatusPath(): string {
|
||||
return join(userDataPath, 'agent-hooks', 'last-status.json')
|
||||
}
|
||||
|
||||
it('routes a reminted $$ key to its canonical pane and settles at done', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
server.registerPaneKeyAlias(REMINTED, PANE, 'pty-remint')
|
||||
const start = await postHookEvent(
|
||||
server,
|
||||
buildBody(
|
||||
{ hook_event_name: 'before_agent_start', prompt: 'finish the reminted pane' },
|
||||
{ paneKey: REMINTED, launchToken: 'launch-remint' }
|
||||
),
|
||||
'/hook/omp'
|
||||
)
|
||||
const end = await postHookEvent(
|
||||
server,
|
||||
buildBody(
|
||||
{ hook_event_name: 'agent_end' },
|
||||
{ paneKey: REMINTED, launchToken: 'launch-remint' }
|
||||
),
|
||||
'/hook/omp'
|
||||
)
|
||||
expect(start.status).toBe(204)
|
||||
expect(end.status).toBe(204)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
state: 'done',
|
||||
prompt: 'finish the reminted pane',
|
||||
agentType: 'omp'
|
||||
})
|
||||
])
|
||||
server.flushStatusPersistSync()
|
||||
expect(hasSavedStatus(lastStatusPath())).toBe(true)
|
||||
const file: {
|
||||
entries: Record<string, { payload: { state: string } }>
|
||||
} = JSON.parse(readSavedStatus(lastStatusPath()))
|
||||
expect(file.entries[PANE]?.payload.state).toBe('done')
|
||||
expect(file.entries[REMINTED]).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let a foreign reminted key stamp another pane', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
server.registerPaneKeyAlias(REMINTED, PANE, 'pty-remint')
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody(
|
||||
{ hook_event_name: 'before_agent_start', prompt: 'owned turn' },
|
||||
{ paneKey: REMINTED }
|
||||
),
|
||||
'/hook/omp'
|
||||
)
|
||||
const foreign = await postHookEvent(
|
||||
server,
|
||||
buildBody(
|
||||
{ hook_event_name: 'agent_end', prompt: 'stolen done' },
|
||||
{ paneKey: FOREIGN, tabId: 'tab-1' }
|
||||
),
|
||||
'/hook/omp'
|
||||
)
|
||||
expect(foreign.status).toBe(204)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
state: 'working',
|
||||
prompt: 'owned turn',
|
||||
agentType: 'omp'
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not rebind a reminted token onto a different pane', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
// Different spawns can share $$ tokens; stale remints must not overwrite the newer pane owner.
|
||||
server.registerPaneKeyAlias(REMINTED, PANE, 'pty-a', 10, { authorityVerified: true })
|
||||
server.registerPaneKeyAlias(REMINTED, GOOD_PANE, 'pty-b', 20, { authorityVerified: true })
|
||||
const start = await postHookEvent(
|
||||
server,
|
||||
buildBody(
|
||||
{ hook_event_name: 'before_agent_start', prompt: 'owned remint turn' },
|
||||
{ paneKey: REMINTED, tabId: 'tab-good' }
|
||||
),
|
||||
'/hook/omp'
|
||||
)
|
||||
expect(start.status).toBe(204)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
state: 'working',
|
||||
prompt: 'owned remint turn',
|
||||
agentType: 'omp'
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,174 +0,0 @@
|
||||
import { readSavedStatus } from '../persistence/last-status.test-fixtures'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
|
||||
import { LEAF_1, LEAF_2, LEAF_3, LEAF_4, LEAF_5, recentTs } from '../server.test-fixtures'
|
||||
|
||||
const REMOVED = 'repo-1::/workspace/removed'
|
||||
const KEPT = 'repo-1::/workspace/kept'
|
||||
const LOCAL_PANE = makePaneKey('tab-local', LEAF_1)
|
||||
const WSL_PANE = makePaneKey('tab-wsl', LEAF_2)
|
||||
const SSH_PANE = makePaneKey('tab-ssh', LEAF_3)
|
||||
const SSH_COMMITMENT_PANE = makePaneKey('tab-ssh-idle', LEAF_4)
|
||||
const OTHER_PANE = makePaneKey('tab-other', LEAF_5)
|
||||
|
||||
function row(paneKey: string, worktreeId: string, connectionId: string | null) {
|
||||
const receivedAt = recentTs()
|
||||
return {
|
||||
paneKey,
|
||||
tabId: paneKey.split(':')[0],
|
||||
worktreeId,
|
||||
connectionId,
|
||||
receivedAt,
|
||||
stateStartedAt: receivedAt,
|
||||
payload: { state: 'working', prompt: 'stranded', agentType: 'codex' }
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentHookServer removed-worktree retirement', () => {
|
||||
let userDataPath: string
|
||||
const lastStatusPath = () => join(userDataPath, 'agent-hooks', 'last-status.json')
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-removed-worktree-'))
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
writeFileSync(
|
||||
lastStatusPath(),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
[LOCAL_PANE]: row(LOCAL_PANE, REMOVED, null),
|
||||
[WSL_PANE]: row(WSL_PANE, REMOVED, 'wsl:Ubuntu'),
|
||||
[SSH_PANE]: row(SSH_PANE, REMOVED, 'user@box'),
|
||||
[OTHER_PANE]: row(OTHER_PANE, KEPT, null)
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('retires only the removing host rows and commitments, then persists the pruned map', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
// An SSH commitment recorded this session outlives its row across a disconnect clear.
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: SSH_COMMITMENT_PANE,
|
||||
tabId: 'tab-ssh-idle',
|
||||
worktreeId: REMOVED,
|
||||
launchToken: 'idle-launch',
|
||||
payload: { state: 'working', prompt: 'idle', agentType: 'codex' }
|
||||
},
|
||||
'idle@box'
|
||||
)
|
||||
server.clearStatusEntriesForConnection('idle@box')
|
||||
const persisted = () => {
|
||||
server.flushStatusPersistSync()
|
||||
const file = JSON.parse(readSavedStatus(lastStatusPath()))
|
||||
return {
|
||||
entries: Object.keys(file.entries).sort(),
|
||||
commitments: Object.keys(file.authorityCommitments ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
server.dropStatusEntriesForRemovedWorktree(REMOVED, 'runtime:env-1')
|
||||
expect(persisted().entries).toHaveLength(4)
|
||||
|
||||
server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local')
|
||||
expect(persisted()).toEqual({
|
||||
entries: [OTHER_PANE, SSH_PANE].sort(),
|
||||
commitments: [SSH_COMMITMENT_PANE]
|
||||
})
|
||||
|
||||
server.dropStatusEntriesForRemovedWorktree(REMOVED, 'ssh:user%40box')
|
||||
expect(persisted()).toEqual({ entries: [OTHER_PANE], commitments: [SSH_COMMITMENT_PANE] })
|
||||
|
||||
server.dropStatusEntriesForRemovedWorktree(REMOVED, 'ssh:idle%40box')
|
||||
expect(persisted().commitments).toEqual([])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('fences only the retired pane, so its kept tab still reports a new agent', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local')
|
||||
const newPane = makePaneKey('tab-local', '66666666-6666-4666-8666-666666666666')
|
||||
const done = { state: 'done', prompt: 'late', agentType: 'codex' } as const
|
||||
server.ingestTerminalStatus({ paneKey: LOCAL_PANE, connectionId: null, payload: done })
|
||||
server.ingestTerminalStatus({ paneKey: newPane, connectionId: null, payload: done })
|
||||
|
||||
const panes = server.getStatusSnapshot().map((entry) => entry.paneKey)
|
||||
expect(panes).toContain(newPane)
|
||||
expect(panes).not.toContain(LOCAL_PANE)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ occupant: 'the removed worktree', sshWorktree: KEPT, localWorktree: REMOVED, host: 'local' },
|
||||
{
|
||||
occupant: 'another owner',
|
||||
sshWorktree: REMOVED,
|
||||
localWorktree: KEPT,
|
||||
host: 'ssh:user%40box'
|
||||
}
|
||||
] as const)(
|
||||
'decides a reused pane by its occupant: $occupant',
|
||||
async ({ sshWorktree, localWorktree, host }) => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
const pane = makePaneKey('tab-reused', '77777777-7777-4777-8777-777777777777')
|
||||
const working = { state: 'working', prompt: 'live', agentType: 'codex' } as const
|
||||
const reportLocally = (state: 'working' | 'done') =>
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: pane,
|
||||
worktreeId: localWorktree,
|
||||
connectionId: null,
|
||||
payload: { ...working, state }
|
||||
})
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: pane,
|
||||
tabId: 'tab-reused',
|
||||
worktreeId: sshWorktree,
|
||||
launchToken: 'ssh',
|
||||
payload: working
|
||||
},
|
||||
'user@box'
|
||||
)
|
||||
server.clearStatusEntriesForConnection('user@box')
|
||||
reportLocally('working')
|
||||
|
||||
server.dropStatusEntriesForRemovedWorktree(REMOVED, host)
|
||||
reportLocally('done')
|
||||
|
||||
server.flushStatusPersistSync()
|
||||
const file = JSON.parse(readSavedStatus(lastStatusPath()))
|
||||
if (localWorktree === REMOVED) {
|
||||
expect(file.authorityCommitments?.[pane]).toMatchObject({ worktreeId: KEPT })
|
||||
expect(file.entries[pane]).toBeUndefined()
|
||||
} else {
|
||||
expect(file.authorityCommitments?.[pane]).toBeUndefined()
|
||||
expect(file.entries[pane]).toMatchObject({ worktreeId: KEPT, payload: { state: 'done' } })
|
||||
expect(file.entries[pane].launchTokenHash).toBeUndefined()
|
||||
}
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { AgentHookServer } from '../../agent-hook-server'
|
||||
import type { ClaudeStatusLineRateLimits } from '../../../../../shared/claude-statusline-rate-limits'
|
||||
|
||||
describe('AgentHookServer /statusline/claude', () => {
|
||||
let server: AgentHookServer
|
||||
|
||||
beforeEach(async () => {
|
||||
server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.stop()
|
||||
})
|
||||
|
||||
function post(body: string, token?: string): Promise<Response> {
|
||||
const env = server.buildPtyEnv()
|
||||
return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/statusline/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Orca-Agent-Hook-Token': token ?? env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
it('forwards parsed rate limits to the statusline listener', async () => {
|
||||
const events: ClaudeStatusLineRateLimits[] = []
|
||||
server.setClaudeStatusLineListener((event) => {
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
const payload = JSON.stringify({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 12.5, resets_at: 1738425600 },
|
||||
seven_day: { used_percentage: 40, resets_at: 1712059200 }
|
||||
}
|
||||
})
|
||||
const body = new URLSearchParams({
|
||||
paneKey: 'pane-1',
|
||||
configDir: '/home/dev/managed',
|
||||
payload
|
||||
}).toString()
|
||||
|
||||
await expect(post(body)).resolves.toMatchObject({ status: 204 })
|
||||
expect(events).toEqual([
|
||||
{
|
||||
configDir: '/home/dev/managed',
|
||||
fiveHour: { used_percentage: 12.5, resets_at: 1738425600 },
|
||||
sevenDay: { used_percentage: 40, resets_at: 1712059200 }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects posts with a bad token and ignores payloads without rate limits', async () => {
|
||||
const events: ClaudeStatusLineRateLimits[] = []
|
||||
server.setClaudeStatusLineListener((event) => {
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
await expect(post('payload={}', 'wrong-token')).resolves.toMatchObject({ status: 403 })
|
||||
|
||||
const noLimits = new URLSearchParams({
|
||||
paneKey: 'pane-1',
|
||||
payload: JSON.stringify({ context_window: { used_percentage: 8 } })
|
||||
}).toString()
|
||||
await expect(post(noLimits)).resolves.toMatchObject({ status: 204 })
|
||||
|
||||
await expect(post('payload=not-json')).resolves.toMatchObject({ status: 204 })
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,170 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { buildBody, PANE } from '../server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({
|
||||
track: trackMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: getCohortAtEmitMock
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('AgentHookServer listener replay', () => {
|
||||
it('accepts raw JSON hook bodies with base64 metadata headers', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN,
|
||||
'X-Orca-Agent-Hook-Meta-Encoding': 'base64',
|
||||
'X-Orca-Agent-Hook-Meta': Buffer.from(
|
||||
[PANE, 'tab-1', '', 'wt-1', 'production', ''].join('\x1f')
|
||||
).toString('base64')
|
||||
},
|
||||
body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'raw JSON' })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
worktreeId: 'wt-1',
|
||||
state: 'working',
|
||||
prompt: 'raw JSON'
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores local nested Claude Stop while a parent Codex hook status is active', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
const postHook = async (
|
||||
source: 'codex' | 'claude',
|
||||
payload: Record<string, unknown>
|
||||
): Promise<void> => {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload))
|
||||
}
|
||||
)
|
||||
expect(response.status).toBe(204)
|
||||
}
|
||||
|
||||
await postHook('codex', {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'parent codex'
|
||||
})
|
||||
await postHook('claude', {
|
||||
hook_event_name: 'Stop',
|
||||
last_assistant_message: 'child finished'
|
||||
})
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
state: 'working',
|
||||
prompt: 'parent codex',
|
||||
agentType: 'codex'
|
||||
})
|
||||
])
|
||||
const snapshot = server.getStatusSnapshot()[0]
|
||||
expect(snapshot.lastAssistantMessage).toBeUndefined()
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'parent codex',
|
||||
agentType: 'codex'
|
||||
})
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts form-encoded hook posts from Unix managed scripts', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const params = new URLSearchParams({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo::/tmp/worktree with "quotes"',
|
||||
env: 'production',
|
||||
version: env.ORCA_AGENT_HOOK_VERSION ?? '',
|
||||
payload: JSON.stringify({
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'form encoded'
|
||||
})
|
||||
})
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: params
|
||||
})
|
||||
expect(response.status).toBe(204)
|
||||
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo::/tmp/worktree with "quotes"',
|
||||
connectionId: null,
|
||||
receivedAt: expect.any(Number),
|
||||
stateStartedAt: expect.any(Number),
|
||||
payload: expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'form encoded',
|
||||
agentType: 'claude'
|
||||
})
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import '../memory-agent-identity-store.test-fixture'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
|
||||
import { AgentHookServer } from '../../agent-hook-server'
|
||||
|
||||
const PANE = makePaneKey('external-live', '11111111-1111-4111-8111-111111111111')
|
||||
const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-live' }
|
||||
|
||||
function seedLocalIdentity(server: AgentHookServer): void {
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
providerSession: PROVIDER_SESSION,
|
||||
payload: { state: 'working', prompt: 'test', agentType: 'gemini' }
|
||||
},
|
||||
'seed-connection'
|
||||
)
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
connectionId: null,
|
||||
payload: { state: 'working', prompt: 'test', agentType: 'gemini' }
|
||||
})
|
||||
}
|
||||
|
||||
describe('AgentHookServer AI Vault liveness identity', () => {
|
||||
it('hydrates dismissed provider identity without reviving visible status', async () => {
|
||||
const userDataPath = await mkdtemp(join(tmpdir(), 'orca-ai-vault-liveness-'))
|
||||
const first = new AgentHookServer()
|
||||
try {
|
||||
await first.start({ env: 'production', userDataPath })
|
||||
seedLocalIdentity(first)
|
||||
first.dropStatusEntry(PANE)
|
||||
first.flushStatusPersistSync()
|
||||
first.stop()
|
||||
|
||||
const second = new AgentHookServer()
|
||||
await second.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
expect(second.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
providerSession: PROVIDER_SESSION,
|
||||
providerSessionOnly: true,
|
||||
restoredUnconfirmed: true
|
||||
})
|
||||
])
|
||||
expect(second.getStatusChangeSnapshot()).toEqual([])
|
||||
} finally {
|
||||
second.stop()
|
||||
}
|
||||
} finally {
|
||||
first.stop()
|
||||
await rm(userDataPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer } from '../../agent-hook-server'
|
||||
import { HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS } from '../../program-status/hook-precedence'
|
||||
import { buildBody, ingestProgramStatusOsc, PANE, postHookEvent } from '../server.test-fixtures'
|
||||
|
||||
const SESSION = '70efcb3f-ae17-4863-9318-649f5469408b'
|
||||
|
||||
let dir: string
|
||||
let server: AgentHookServer
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-claude-continuation-'))
|
||||
server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath: dir })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
server.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function claudeHook(payload: Record<string, unknown>): Promise<void> {
|
||||
const response = await postHookEvent(server, buildBody({ session_id: SESSION, ...payload }))
|
||||
expect(response.status).toBe(204)
|
||||
}
|
||||
|
||||
function recordedSession() {
|
||||
return server.getStatusSnapshot()[0]?.providerSession
|
||||
}
|
||||
|
||||
function seedResume(providerSession: { cwd?: string; effort?: string }): void {
|
||||
server.recordLaunchResumeIdentity({
|
||||
paneKey: PANE,
|
||||
worktreeId: 'wt-1',
|
||||
agentType: 'claude',
|
||||
providerSession: { key: 'session_id', id: SESSION, ...providerSession },
|
||||
launchedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps the start folder and effort when a terminal status row precedes the next hook', async () => {
|
||||
await claudeHook({ hook_event_name: 'SessionStart', source: 'startup', cwd: '/repo' })
|
||||
await claudeHook({
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
cwd: '/repo/sub',
|
||||
effort: { level: 'max' }
|
||||
})
|
||||
await claudeHook({ hook_event_name: 'Stop', cwd: '/repo/sub', effort: { level: 'max' } })
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(Date.now() + HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS)
|
||||
// Claude reports OSC 7501 `working` just before UserPromptSubmit.
|
||||
ingestProgramStatusOsc(server, '\x1b]7501;state=working:app=claude-code\x1b\\')
|
||||
await claudeHook({ hook_event_name: 'UserPromptSubmit', prompt: 'next', cwd: '/repo/sub' })
|
||||
|
||||
expect(recordedSession()).toMatchObject({ id: SESSION, cwd: '/repo', effort: 'max' })
|
||||
})
|
||||
|
||||
it('records where a resumed process really started over the folder its seed named', async () => {
|
||||
seedResume({ cwd: '/repo' })
|
||||
await claudeHook({ hook_event_name: 'SessionStart', source: 'resume', cwd: '/elsewhere' })
|
||||
|
||||
expect(recordedSession()?.cwd).toBe('/elsewhere')
|
||||
})
|
||||
|
||||
it('names the pane session from a --fork-session start, which Claude 2.1.214+ reports as fork', async () => {
|
||||
// Captured from Claude 2.1.321 `claude --resume <id> --fork-session` before any message.
|
||||
await claudeHook({
|
||||
session_id: 'b47ec7fc-07e4-4012-8b28-1d5daa938f52',
|
||||
transcript_path:
|
||||
'/Users/u/.claude/projects/-private-tmp-w39942fork/b47ec7fc-07e4-4012-8b28-1d5daa938f52.jsonl',
|
||||
cwd: '/private/tmp/w39942fork',
|
||||
hook_event_name: 'SessionStart',
|
||||
source: 'fork'
|
||||
})
|
||||
|
||||
expect(recordedSession()).toMatchObject({
|
||||
id: 'b47ec7fc-07e4-4012-8b28-1d5daa938f52',
|
||||
cwd: '/private/tmp/w39942fork'
|
||||
})
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { buildBody, postHookEvent, PANE } from '../server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({ track: trackMock }))
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
|
||||
|
||||
const RESUMED = { key: 'session_id' as const, id: 'resumed-session' }
|
||||
|
||||
// Codex may stay silent until the resumed pane's first turn; seed identity at launch for every reader.
|
||||
describe('a resume launch seeds the pane identity', () => {
|
||||
const servers: AgentHookServer[] = []
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
getCohortAtEmitMock.mockReturnValue({})
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-launch-resume-identity-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop()
|
||||
}
|
||||
servers.length = 0
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function startServer(): Promise<AgentHookServer> {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
return server
|
||||
}
|
||||
|
||||
it('yields to a newer agent report', async () => {
|
||||
const server = await startServer()
|
||||
const launchedAt = Date.now() - 1_000
|
||||
const response = await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SessionStart', session_id: 'agent-reported' }),
|
||||
'/hook/codex'
|
||||
)
|
||||
expect(response.status).toBe(204)
|
||||
server.recordLaunchResumeIdentity({
|
||||
paneKey: PANE,
|
||||
agentType: 'codex',
|
||||
providerSession: RESUMED,
|
||||
launchedAt
|
||||
})
|
||||
expect(server.getStatusSnapshotForPane(PANE)[0]?.providerSession?.id).toBe('agent-reported')
|
||||
})
|
||||
|
||||
it("replaces a row left by the pane's previous process", async () => {
|
||||
const server = await startServer()
|
||||
const response = await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'Stop', session_id: 'previous-run' }),
|
||||
'/hook/codex'
|
||||
)
|
||||
expect(response.status).toBe(204)
|
||||
server.recordLaunchResumeIdentity({
|
||||
paneKey: PANE,
|
||||
agentType: 'codex',
|
||||
providerSession: RESUMED,
|
||||
launchedAt: Date.now() + 1_000
|
||||
})
|
||||
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
|
||||
providerSession: RESUMED,
|
||||
providerSessionOnly: true
|
||||
})
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', session_id: RESUMED.id, prompt: 'go on' }),
|
||||
'/hook/codex'
|
||||
)
|
||||
const [row] = server.getStatusSnapshotForPane(PANE)
|
||||
expect(row?.providerSessionOnly).toBeUndefined()
|
||||
expect(row).toMatchObject({ state: 'working', providerSession: RESUMED })
|
||||
})
|
||||
|
||||
it('keeps the seeded identity when terminal evidence starts the first turn', async () => {
|
||||
const server = await startServer()
|
||||
server.recordLaunchResumeIdentity({
|
||||
paneKey: PANE,
|
||||
agentType: 'opencode2',
|
||||
providerSession: RESUMED,
|
||||
launchedAt: Date.now()
|
||||
})
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
origin: 'process',
|
||||
payload: { state: 'working', prompt: '', agentType: 'opencode2' }
|
||||
})
|
||||
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
|
||||
state: 'working',
|
||||
providerSession: RESUMED
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the seeded identity across a host restart', async () => {
|
||||
const server = await startServer()
|
||||
server.recordLaunchResumeIdentity({
|
||||
paneKey: PANE,
|
||||
worktreeId: 'wt-1',
|
||||
agentType: 'codex',
|
||||
providerSession: RESUMED,
|
||||
launchedAt: Date.now()
|
||||
})
|
||||
server.stop()
|
||||
servers.length = 0
|
||||
const restarted = await startServer()
|
||||
expect(restarted.getStatusSnapshotForPane(PANE)).toEqual([
|
||||
expect.objectContaining({
|
||||
agentType: 'codex',
|
||||
providerSession: RESUMED,
|
||||
providerSessionOnly: true
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer } from '../../agent-hook-server'
|
||||
import { buildBody, PANE, postHookEvent } from '../server.test-fixtures'
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 }))
|
||||
}))
|
||||
|
||||
const route = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1' }
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('nested provider ownership boundaries', () => {
|
||||
it.each([
|
||||
['ssh-b', 'wt-1', 'codex'],
|
||||
['conn-1', 'wt-2', 'codex'],
|
||||
['conn-1', 'wt-1', 'claude']
|
||||
] as const)(
|
||||
'scopes ownership to host %s and workspace %s',
|
||||
(connectionId, worktreeId, agentType) => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{ ...route, payload: { state: 'working', prompt: 'Parent', agentType: 'claude' } },
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestRemote(
|
||||
{
|
||||
...route,
|
||||
worktreeId,
|
||||
payload: { state: 'working', prompt: 'Other', agentType: 'codex' }
|
||||
},
|
||||
connectionId
|
||||
)
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ agentType, connectionId, worktreeId })
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
[null, 'wsl:Ubuntu'],
|
||||
['wsl:Ubuntu', null]
|
||||
] as const)('retains WSL ownership across %s to %s transport', (before, after) => {
|
||||
const server = new AgentHookServer()
|
||||
const worktreeId = 'repo:://wsl.localhost/Ubuntu/home/user/project'
|
||||
server.ingestRemote(
|
||||
{
|
||||
...route,
|
||||
worktreeId,
|
||||
payload: { state: 'working', prompt: 'Parent', agentType: 'claude' }
|
||||
},
|
||||
before
|
||||
)
|
||||
server.ingestRemote(
|
||||
{ ...route, worktreeId, payload: { state: 'working', prompt: 'Nested', agentType: 'codex' } },
|
||||
after
|
||||
)
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ agentType: 'claude', prompt: 'Parent' })
|
||||
})
|
||||
|
||||
it('admits a local HTTP owner despite a remote pane-key collision', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
server.ingestRemote(
|
||||
{ ...route, payload: { state: 'working', prompt: 'Remote', agentType: 'claude' } },
|
||||
'ssh-a'
|
||||
)
|
||||
const response = await postHookEvent(
|
||||
server,
|
||||
buildBody({
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
session_id: 'local-codex',
|
||||
prompt: 'Local'
|
||||
}),
|
||||
'/hook/codex'
|
||||
)
|
||||
expect(response.status).toBe(204)
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
connectionId: null,
|
||||
agentType: 'codex',
|
||||
prompt: 'Local',
|
||||
providerSession: { id: 'local-codex' }
|
||||
})
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps nested Pi identity-only metadata off an active owner and permits it after completion', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
...route,
|
||||
payload: { state: 'working', prompt: 'Parent work', agentType: 'claude' },
|
||||
providerSession: { key: 'session_id', id: 'claude-parent' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const before = server.getStatusSnapshot()
|
||||
const pi = {
|
||||
...route,
|
||||
providerSessionOnly: true,
|
||||
providerSession: {
|
||||
key: 'session_id' as const,
|
||||
id: 'pi-child',
|
||||
transcriptPath: '/tmp/pi-child.jsonl'
|
||||
},
|
||||
payload: { state: 'done' as const, prompt: '', agentType: 'pi' as const }
|
||||
}
|
||||
server.ingestRemote(pi, 'conn-1')
|
||||
expect(server.getStatusSnapshot()).toEqual(before)
|
||||
server.ingestRemote(
|
||||
{ ...route, payload: { state: 'done', prompt: 'Parent work', agentType: 'claude' } },
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestRemote(pi, 'conn-1')
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
agentType: 'pi',
|
||||
providerSessionOnly: true,
|
||||
providerSession: { id: 'pi-child' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { buildBody, postHookEvent } from '../server.test-fixtures'
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({
|
||||
track: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 }))
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('AgentHookServer nested agent model fence', () => {
|
||||
// Readers retain sparse model labels, so a transient child label could remain on the parent.
|
||||
it('never pushes a nested codex model to the renderer from real hook bodies', async () => {
|
||||
vi.useRealTimers()
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
const pushed: { agentType?: string; model?: string }[] = []
|
||||
server.setListener((event) => pushed.push(event.payload))
|
||||
try {
|
||||
const steps: [string, Record<string, unknown>][] = [
|
||||
['/hook/claude', { hook_event_name: 'UserPromptSubmit', session_id: 'c-1', prompt: 'p' }],
|
||||
[
|
||||
'/hook/codex',
|
||||
{
|
||||
hook_event_name: 'SessionStart',
|
||||
session_id: 'x-1',
|
||||
model: 'gpt-6-astra',
|
||||
source: 'startup'
|
||||
}
|
||||
],
|
||||
[
|
||||
'/hook/codex',
|
||||
{
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
session_id: 'x-1',
|
||||
prompt: 'n',
|
||||
model: 'gpt-6-astra'
|
||||
}
|
||||
],
|
||||
[
|
||||
'/hook/codex',
|
||||
{
|
||||
hook_event_name: 'PreToolUse',
|
||||
session_id: 'x-1',
|
||||
model: 'gpt-6-astra',
|
||||
tool_name: 'shell'
|
||||
}
|
||||
],
|
||||
[
|
||||
'/hook/codex',
|
||||
{
|
||||
hook_event_name: 'Stop',
|
||||
session_id: 'x-1',
|
||||
model: 'gpt-6-astra',
|
||||
stop_hook_active: false
|
||||
}
|
||||
],
|
||||
['/hook/claude', { hook_event_name: 'Stop', session_id: 'c-1' }]
|
||||
]
|
||||
for (const [path, body] of steps) {
|
||||
expect((await postHookEvent(server, buildBody(body), path)).status).toBe(204)
|
||||
}
|
||||
const [row] = server.getStatusSnapshot()
|
||||
expect(row).toMatchObject({ agentType: 'claude', state: 'done' })
|
||||
expect(row?.model).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
|
||||
expect(pushed).toHaveLength(2)
|
||||
expect(pushed.every((payload) => payload.agentType === 'claude')).toBe(true)
|
||||
expect(pushed.map((payload) => payload.model)).not.toContain('gpt-6-astra')
|
||||
})
|
||||
})
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer } from '../../agent-hook-server'
|
||||
import { buildBody, PANE, postHookEvent } from '../server.test-fixtures'
|
||||
import type { probeAgentProcessPresence } from '../../../../../shared/agent-status/process/agent-process-presence-probe'
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) }))
|
||||
const probe = vi.hoisted(() => vi.fn<typeof probeAgentProcessPresence>(async () => 'live'))
|
||||
vi.mock('../../../../../shared/agent-status/process/agent-process-presence-probe', () => ({
|
||||
probeAgentProcessPresence: probe
|
||||
}))
|
||||
const servers: AgentHookServer[] = []
|
||||
const parent = { pid: 4001, platform: 'linux', startTime: 'birth-4001' }
|
||||
const child = { pid: 4002, platform: 'linux', startTime: 'birth-4002' }
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers.splice(0)) {
|
||||
server.stop()
|
||||
}
|
||||
probe.mockReset().mockResolvedValue('live')
|
||||
})
|
||||
|
||||
async function claudeHook(
|
||||
server: AgentHookServer,
|
||||
process: typeof parent,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await postHookEvent(server, buildBody(payload, { agentProcess: JSON.stringify(process) }))
|
||||
}
|
||||
|
||||
async function parentWorking(): Promise<AgentHookServer> {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
await server.start({ env: 'production' })
|
||||
await claudeHook(server, parent, {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
session_id: 'claude-parent',
|
||||
prompt: 'Parent task'
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
describe('a second Claude process in the same pane', () => {
|
||||
it('does not replace the live owner row when a nested claude -p fails', async () => {
|
||||
const server = await parentWorking()
|
||||
const emitted = vi.fn()
|
||||
server.setListener(emitted)
|
||||
await claudeHook(server, child, {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
session_id: 'claude-child',
|
||||
prompt: 'Child task'
|
||||
})
|
||||
await claudeHook(server, child, {
|
||||
hook_event_name: 'StopFailure',
|
||||
session_id: 'claude-child',
|
||||
error: 'rate_limit'
|
||||
})
|
||||
const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)
|
||||
expect(row).toMatchObject({
|
||||
state: 'working',
|
||||
prompt: 'Parent task',
|
||||
providerSession: { id: 'claude-parent' }
|
||||
})
|
||||
expect(emitted.mock.calls.some(([event]) => event.payload.state === 'done')).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a new Claude take the pane once the old owner process exited', async () => {
|
||||
const server = await parentWorking()
|
||||
probe.mockResolvedValue('exited')
|
||||
await claudeHook(server, child, {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
session_id: 'claude-next',
|
||||
prompt: 'Next task'
|
||||
})
|
||||
const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)
|
||||
expect(row).toMatchObject({
|
||||
state: 'working',
|
||||
prompt: 'Next task',
|
||||
providerSession: { id: 'claude-next' }
|
||||
})
|
||||
})
|
||||
})
|
||||
-410
@@ -1,410 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { buildBody, PANE, RUNNING_SHELL } from '../server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({
|
||||
track: trackMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: getCohortAtEmitMock
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('AgentHookServer listener replay', () => {
|
||||
it('infers a cancel of a live Claude main agent turn and keeps the shell it leaves running as monitoring', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
claudeRunningNonAgentTask: true,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'run in background',
|
||||
agentType: 'claude',
|
||||
mainAgent: { state: 'working', stateStartedAt: 900 }
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'run in background',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
// Cancel the main agent without discarding live shell inventory or marking the working row interrupted.
|
||||
const inferred = server.getStatusSnapshot()[0]
|
||||
expect(inferred).toMatchObject({
|
||||
state: 'working',
|
||||
workingMode: 'monitoring',
|
||||
mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_500 }
|
||||
})
|
||||
expect(inferred.interrupted).toBeUndefined()
|
||||
expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it("starts a relayed Claude pane's cancel clock at each cancel, not at an earlier one", () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
const relayTurn = (prompt: string, startedAt: number): void =>
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt,
|
||||
agentType: 'claude',
|
||||
mainAgent: { state: 'working', stateStartedAt: startedAt }
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const cancel = (prompt: string): boolean => {
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
return server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: prompt,
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
}
|
||||
relayTurn('first', 900)
|
||||
vi.setSystemTime(1_500)
|
||||
expect(cancel('first')).toBe(true)
|
||||
|
||||
// Relay-owned turns must not inherit the local record's earlier cancellation clock.
|
||||
vi.setSystemTime(5_000)
|
||||
relayTurn('second', 5_000)
|
||||
vi.setSystemTime(6_000)
|
||||
expect(cancel('second')).toBe(true)
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 6_000 }
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('infers a cancel of a live Claude main agent turn beside a working subagent and keeps the row working', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'delegate',
|
||||
agentType: 'claude',
|
||||
subagents: [{ id: 'a1', state: 'working', startedAt: 900 }],
|
||||
mainAgent: { state: 'working', stateStartedAt: 900 }
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'delegate',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
// Relayed rows carry their own child evidence; main-agent cancellation must preserve it.
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
subagents: [{ id: 'a1', state: 'working' }],
|
||||
mainAgent: { state: 'done', outcome: 'cancellation' }
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0].workingMode).toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not infer a cancel at the idle prompt of a Claude row held open by child work', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
claudeRunningNonAgentTask: true,
|
||||
payload: {
|
||||
state: 'working',
|
||||
workingMode: 'monitoring',
|
||||
prompt: 'run in background',
|
||||
agentType: 'claude',
|
||||
mainAgent: { state: 'done', stateStartedAt: 900 }
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
// Ctrl+C at an idle lead's prompt stops none of the child work shown.
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'run in background',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
workingMode: 'monitoring',
|
||||
mainAgent: { state: 'done' }
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('blocks local HTTP interrupt inference for provider-owned work without exposing transport metadata', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload))
|
||||
})
|
||||
|
||||
await expect(
|
||||
postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' })
|
||||
).resolves.toMatchObject({ status: 204 })
|
||||
await expect(
|
||||
postHook({ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] })
|
||||
).resolves.toMatchObject({ status: 204 })
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask')
|
||||
expect(baseline).toMatchObject({ state: 'working', workingMode: 'monitoring' })
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'run in background',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
await expect(
|
||||
postHook({
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [],
|
||||
session_crons: [{ id: 'cron-1' }]
|
||||
})
|
||||
).resolves.toMatchObject({ status: 204 })
|
||||
const cronBaseline = server.getStatusSnapshot()[0]
|
||||
expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(true)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: cronBaseline.receivedAt,
|
||||
baselineStateStartedAt: cronBaseline.stateStartedAt,
|
||||
baselinePrompt: 'run in background',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses replayed Claude background metadata only before a live observation', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
isReplay: true,
|
||||
claudeRunningNonAgentTask: true,
|
||||
payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'stale replay',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
claudeRunningNonAgentTask: false,
|
||||
payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
vi.setSystemTime(2_000)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
isReplay: true,
|
||||
claudeRunningNonAgentTask: true,
|
||||
payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const liveBaseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(2_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: liveBaseline.receivedAt,
|
||||
baselineStateStartedAt: liveBaseline.stateStartedAt,
|
||||
baselinePrompt: 'stale replay',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'long task', agentType: 'opencode' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'long task',
|
||||
baselineAgentType: 'opencode',
|
||||
intent: 'plain-escape',
|
||||
inputCount: 2
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(1_501)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'done', prompt: 'long task', agentType: 'opencode' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
state: 'done',
|
||||
prompt: 'long task',
|
||||
agentType: 'opencode',
|
||||
interrupted: true,
|
||||
receivedAt: 1_500,
|
||||
stateStartedAt: 1_500
|
||||
})
|
||||
])
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
payload: expect.objectContaining({ state: 'done', interrupted: true })
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
-262
@@ -1,262 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { PANE } from '../server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({
|
||||
track: trackMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: getCohortAtEmitMock
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('AgentHookServer listener replay', () => {
|
||||
it('does not let late same-turn working hooks resurrect an inferred interrupt', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'long task', agentType: 'pi' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'long task',
|
||||
baselineAgentType: 'pi',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(6_000)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'long task',
|
||||
agentType: 'pi',
|
||||
toolName: 'bash',
|
||||
toolInput: '/bin/sleep 90'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'done',
|
||||
prompt: 'long task',
|
||||
agentType: 'pi',
|
||||
interrupted: true,
|
||||
receivedAt: 1_500,
|
||||
stateStartedAt: 1_500
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows an immediate same-prompt retry after an inferred interrupt', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hasExplicitPrompt: true,
|
||||
payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'retryable task',
|
||||
baselineAgentType: 'pi',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(2_000)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hasExplicitPrompt: true,
|
||||
payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'retryable task',
|
||||
agentType: 'pi',
|
||||
receivedAt: 2_000,
|
||||
stateStartedAt: 2_000
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('suppresses same-turn Claude tool progress after the stale suppression window', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hasExplicitPrompt: true,
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
payload: { state: 'working', prompt: 'repeat task', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'repeat task',
|
||||
baselineAgentType: 'claude',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(16_501)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hasExplicitPrompt: true,
|
||||
hookEventName: 'PostToolUse',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'repeat task',
|
||||
agentType: 'claude',
|
||||
toolName: 'bash',
|
||||
toolInput: '/bin/sleep 90'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'done',
|
||||
prompt: 'repeat task',
|
||||
agentType: 'claude',
|
||||
interrupted: true,
|
||||
receivedAt: 1_500,
|
||||
stateStartedAt: 1_500
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows generic non-explicit same-prompt working after the stale suppression window', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'repeat task',
|
||||
baselineAgentType: 'pi',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(16_501)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'repeat task',
|
||||
agentType: 'pi',
|
||||
interrupted: undefined,
|
||||
receivedAt: 16_501,
|
||||
stateStartedAt: 16_501
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { buildBody, postHookEvent, PANE } from '../server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({ track: trackMock }))
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
// Fake only Date so real hook sockets keep working.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(1_000_000)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('main agent turn verdicts and clocks', () => {
|
||||
let server: AgentHookServer
|
||||
|
||||
beforeEach(async () => {
|
||||
server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.stop()
|
||||
})
|
||||
|
||||
async function post(path: string, payload: Record<string, unknown>): Promise<void> {
|
||||
const response = await postHookEvent(server, buildBody(payload), path)
|
||||
expect(response.status).toBe(204)
|
||||
}
|
||||
|
||||
it.each(['idle_prompt notification', 'session end'] as const)(
|
||||
'keeps a cancelled Grok turn verdict when a %s restates done',
|
||||
async (restatement) => {
|
||||
const turn = { sessionId: 'session-1', promptId: 'prompt-1' }
|
||||
await post('/hook/grok', { hookEventName: 'user_prompt_submit', ...turn, prompt: 'go' })
|
||||
await post('/hook/grok', { hookEventName: 'stop_cancelled', ...turn })
|
||||
const cancelled = server.getStatusSnapshot()[0]?.mainAgent
|
||||
expect(cancelled).toMatchObject({ state: 'done', outcome: 'cancellation' })
|
||||
|
||||
// Advance past late-event suppression so the restatement is published.
|
||||
vi.setSystemTime(1_030_000)
|
||||
await post(
|
||||
'/hook/grok',
|
||||
restatement === 'session end'
|
||||
? { hookEventName: 'session_end', ...turn }
|
||||
: { hookEventName: 'notification', ...turn, notificationType: 'idle_prompt' }
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', mainAgent: cancelled })
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a recorded Codex cancellation across a late root Stop', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'codex-turn-verdict-'))
|
||||
const transcriptPath = join(directory, 'rollout.jsonl')
|
||||
try {
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
'{"type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}\n'
|
||||
)
|
||||
await post('/hook/codex', {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'long task',
|
||||
transcript_path: transcriptPath
|
||||
})
|
||||
vi.setSystemTime(1_001_000)
|
||||
appendFileSync(
|
||||
transcriptPath,
|
||||
'{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"turn-1","reason":"interrupted"}}\n'
|
||||
)
|
||||
await vi.waitFor(
|
||||
() => expect(server.getStatusSnapshot()[0]?.mainAgent?.outcome).toBe('cancellation'),
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
const cancelled = server.getStatusSnapshot()[0]?.mainAgent
|
||||
expect(cancelled).toMatchObject({ state: 'done', outcome: 'cancellation' })
|
||||
|
||||
vi.setSystemTime(1_002_000)
|
||||
await post('/hook/codex', { hook_event_name: 'Stop' })
|
||||
// Older readers need interrupted even when they cannot read mainAgent.
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', interrupted: true })
|
||||
// Advance past suppression before child activity republishes the main agent.
|
||||
vi.setSystemTime(1_060_000)
|
||||
await post('/hook/codex', { hook_event_name: 'SubagentStart', agent_id: 'child-1' })
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', mainAgent: cancelled })
|
||||
})
|
||||
|
||||
it('keeps a host-confirmed Codex cancellation across a late relayed root Stop', () => {
|
||||
const relayed = (
|
||||
hookEventName: string,
|
||||
payload: Record<string, unknown>,
|
||||
extra: Record<string, unknown> = {}
|
||||
) =>
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hookEventName,
|
||||
...extra,
|
||||
payload: { prompt: 'long task', agentType: 'codex', ...payload }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
relayed('UserPromptSubmit', { state: 'working' }, { hasExplicitPrompt: true })
|
||||
vi.setSystemTime(1_001_000)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: {
|
||||
prompt: 'long task',
|
||||
agentType: 'codex',
|
||||
state: 'done',
|
||||
interrupted: true,
|
||||
mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_001_000 }
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const cancelled = server.getStatusSnapshot()[0]?.mainAgent
|
||||
expect(cancelled).toMatchObject({ state: 'done', outcome: 'cancellation' })
|
||||
|
||||
vi.setSystemTime(1_002_000)
|
||||
relayed('Stop', { state: 'done' })
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', interrupted: true })
|
||||
vi.setSystemTime(1_060_000)
|
||||
relayed(
|
||||
'SubagentStart',
|
||||
{
|
||||
state: 'working',
|
||||
subagents: [{ id: 'child-1', state: 'working', startedAt: 1_060_000 }]
|
||||
},
|
||||
{ toolAgentId: 'child-1' }
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', mainAgent: cancelled })
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
import { readSavedStatus } from '../persistence/last-status.test-fixtures'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { buildBody, postHookEvent, PANE } from '../server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({ track: trackMock }))
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
getCohortAtEmitMock.mockReturnValue({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Separate host timestamps by advancing real time between events.
|
||||
async function nextMillisecond(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
|
||||
// Host-stamped turn starts survive reader reloads without reconstructing state history.
|
||||
describe('the host-stamped turn start', () => {
|
||||
const servers: AgentHookServer[] = []
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-turn-started-at-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop()
|
||||
}
|
||||
servers.length = 0
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function startServer(): Promise<AgentHookServer> {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
return server
|
||||
}
|
||||
|
||||
async function post(server: AgentHookServer, payload: Record<string, unknown>): Promise<void> {
|
||||
const response = await postHookEvent(server, buildBody(payload))
|
||||
expect(response.status).toBe(204)
|
||||
await nextMillisecond()
|
||||
}
|
||||
|
||||
function turnStartedAt(server: AgentHookServer): number | undefined {
|
||||
return server.getStatusSnapshot()[0]?.turnStartedAt
|
||||
}
|
||||
|
||||
it('is not stamped by a child agent, and the same prompt sent again is a new turn', async () => {
|
||||
const server = await startServer()
|
||||
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' })
|
||||
const started = turnStartedAt(server)
|
||||
|
||||
await post(server, {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'child task',
|
||||
agent_id: 'child-1'
|
||||
})
|
||||
expect(turnStartedAt(server)).toBe(started)
|
||||
|
||||
// Esc at a permission prompt fires no hook, so the resend follows the stale wait directly.
|
||||
await post(server, { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: {} })
|
||||
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' })
|
||||
expect(turnStartedAt(server)).toBeGreaterThan(started!)
|
||||
})
|
||||
|
||||
it('is not stamped by a relayed replay', async () => {
|
||||
const server = await startServer()
|
||||
const envelope = {
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
source: 'claude' as const,
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
payload: { state: 'working' as const, prompt: 'relayed', agentType: 'claude' as const }
|
||||
}
|
||||
server.ingestRemote(envelope, 'ssh-turn')
|
||||
const started = turnStartedAt(server)
|
||||
expect(started).toEqual(expect.any(Number))
|
||||
await nextMillisecond()
|
||||
|
||||
server.ingestRemote({ ...envelope, isReplay: true }, 'ssh-turn')
|
||||
expect(turnStartedAt(server)).toBe(started)
|
||||
})
|
||||
|
||||
// An OSC repaint can land before the prompt hook; it must not count from the finished turn.
|
||||
it('drops the stamp when a finished main agent runs again with no turn-opening event', async () => {
|
||||
const server = await startServer()
|
||||
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' })
|
||||
await post(server, { hook_event_name: 'Stop' })
|
||||
expect(turnStartedAt(server)).toEqual(expect.any(Number))
|
||||
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'Rename the module', agentType: 'claude' }
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
|
||||
expect(server.getStatusSnapshot()[0]).not.toHaveProperty('turnStartedAt')
|
||||
})
|
||||
|
||||
it('reaches the live push and survives a restart through the status file', async () => {
|
||||
const server = await startServer()
|
||||
const pushed: (number | undefined)[] = []
|
||||
server.setListener((status) => pushed.push(status.turnStartedAt))
|
||||
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' })
|
||||
await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: {} })
|
||||
const started = turnStartedAt(server)
|
||||
expect(pushed.at(-1)).toBe(started)
|
||||
server.flushStatusPersistSync()
|
||||
server.stop()
|
||||
const file = JSON.parse(readSavedStatus(join(userDataPath, 'agent-hooks', 'last-status.json')))
|
||||
expect(file.entries[PANE].turnStartedAt).toBe(started)
|
||||
|
||||
const restarted = await startServer()
|
||||
expect(restarted.getStatusSnapshot()[0]).toMatchObject({
|
||||
restoredUnconfirmed: true,
|
||||
turnStartedAt: started
|
||||
})
|
||||
await post(restarted, { hook_event_name: 'PostToolUse', tool_name: 'Bash', tool_input: {} })
|
||||
expect(turnStartedAt(restarted)).toBe(started)
|
||||
})
|
||||
})
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { buildBody, PANE } from '../server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({ track: trackMock }))
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Claude child permission lifecycle', () => {
|
||||
async function createServer(): Promise<{
|
||||
server: AgentHookServer
|
||||
postClaudeHook: (payload: Record<string, unknown>) => Promise<Response>
|
||||
}> {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
const env = server.buildPtyEnv()
|
||||
return {
|
||||
server,
|
||||
postClaudeHook: (payload) =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('clears a child permission when an API error ends that child', async () => {
|
||||
const { server, postClaudeHook } = await createServer()
|
||||
try {
|
||||
await postClaudeHook({ hook_event_name: 'UserPromptSubmit', prompt: 'guarded task' })
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: 'a-blocked',
|
||||
agent_type: 'general-purpose',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'false' }
|
||||
})
|
||||
// Lead progress replaces the listener wait slot; the server must retain the child prompt.
|
||||
await postClaudeHook({ hook_event_name: 'PreToolUse', tool_name: 'Read' })
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'StopFailure',
|
||||
agent_id: 'a-other',
|
||||
error: 'rate_limit'
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' })
|
||||
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'StopFailure',
|
||||
agent_id: 'a-blocked',
|
||||
agent_type: 'general-purpose',
|
||||
error: 'rate_limit'
|
||||
})
|
||||
|
||||
const status = server.getStatusSnapshot()[0]
|
||||
expect(status).toMatchObject({ paneKey: PANE, state: 'working', agentType: 'claude' })
|
||||
expect(status?.toolName).toBe('Read')
|
||||
expect(status?.interactivePrompt).toBeUndefined()
|
||||
expect(status?.subagents).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('settles a finished lead when its background child fails on a pending permission', async () => {
|
||||
const { server, postClaudeHook } = await createServer()
|
||||
try {
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'research in background'
|
||||
})
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'a6324370b7bede0c7',
|
||||
agent_type: 'general-purpose'
|
||||
})
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'Stop',
|
||||
last_assistant_message: 'Started the research.',
|
||||
background_tasks: [{ id: 'a6324370b7bede0c7', type: 'subagent', status: 'running' }]
|
||||
})
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: 'a6324370b7bede0c7',
|
||||
agent_type: 'general-purpose',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'false' }
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' })
|
||||
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'StopFailure',
|
||||
agent_id: 'a6324370b7bede0c7',
|
||||
agent_type: 'general-purpose',
|
||||
error: 'rate_limit'
|
||||
})
|
||||
|
||||
const status = server.getStatusSnapshot()[0]
|
||||
expect(status).toMatchObject({ paneKey: PANE, state: 'done', agentType: 'claude' })
|
||||
expect(status?.interactivePrompt).toBeUndefined()
|
||||
expect(status?.subagents).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
-212
@@ -1,212 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { buildBody, postHookEvent, PANE, RUNNING_SHELL } from '../server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({ track: trackMock }))
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const CHILD_PERMISSION = {
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: 'achild-a',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'rm scratch' }
|
||||
}
|
||||
|
||||
// Persist the settled main-agent fact so child lifecycle hooks, rather than OSC, can settle the restored row.
|
||||
describe('Claude rows held open by child agents', () => {
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-child-held-main-agent-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function startServer(): Promise<AgentHookServer> {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
return server
|
||||
}
|
||||
|
||||
async function restart(server: AgentHookServer): Promise<AgentHookServer> {
|
||||
server.flushStatusPersistSync()
|
||||
server.stop()
|
||||
return startServer()
|
||||
}
|
||||
|
||||
async function post(server: AgentHookServer, payload: Record<string, unknown>): Promise<void> {
|
||||
await postHookEvent(server, buildBody(payload))
|
||||
}
|
||||
|
||||
async function settleMainAgentBeside(server: AgentHookServer, childIds: string[]): Promise<void> {
|
||||
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' })
|
||||
for (const id of childIds) {
|
||||
await post(server, { hook_event_name: 'SubagentStart', agent_id: id })
|
||||
}
|
||||
await post(server, { hook_event_name: 'Stop' })
|
||||
}
|
||||
|
||||
function row(server: AgentHookServer) {
|
||||
return server.getStatusSnapshot()[0]
|
||||
}
|
||||
|
||||
it('settles a restored child permission wait once the child is approved and stops', async () => {
|
||||
let server = await startServer()
|
||||
await settleMainAgentBeside(server, ['achild-a'])
|
||||
await post(server, CHILD_PERMISSION)
|
||||
server = await restart(server)
|
||||
try {
|
||||
await post(server, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
agent_id: 'achild-a',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'rm scratch' },
|
||||
tool_use_id: 'toolu-approved'
|
||||
})
|
||||
expect(row(server)?.state).toBe('working')
|
||||
await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' })
|
||||
|
||||
expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } })
|
||||
expect(row(server)?.restoredUnconfirmed).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not settle a restored row whose main agent resumed behind a child permission', async () => {
|
||||
let server = await startServer()
|
||||
await settleMainAgentBeside(server, ['achild-a', 'achild-b'])
|
||||
await post(server, CHILD_PERMISSION)
|
||||
await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Read' })
|
||||
expect(row(server)).toMatchObject({
|
||||
state: 'waiting',
|
||||
toolName: 'Bash',
|
||||
mainAgent: { state: 'working' }
|
||||
})
|
||||
server = await restart(server)
|
||||
try {
|
||||
await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' })
|
||||
await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-b' })
|
||||
|
||||
expect(row(server)).toMatchObject({ state: 'working', restoredUnconfirmed: true })
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('settles a restored row after an inferred child answer when no shell ran', async () => {
|
||||
let server = await startServer()
|
||||
await settleMainAgentBeside(server, ['achild-a'])
|
||||
await post(server, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
agent_id: 'achild-a',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_use_id: 'toolu-question'
|
||||
})
|
||||
const waiting = row(server)
|
||||
expect(
|
||||
server.inferQuestionAnswered({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: waiting?.receivedAt ?? 0,
|
||||
baselineStateStartedAt: waiting?.stateStartedAt ?? 0,
|
||||
baselinePrompt: waiting?.prompt ?? '',
|
||||
baselineAgentType: 'claude'
|
||||
})
|
||||
).toBe(true)
|
||||
server = await restart(server)
|
||||
try {
|
||||
await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' })
|
||||
|
||||
expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } })
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// Preserve shell evidence beside mainAgent so restart cannot mistake the row for shell-free.
|
||||
async function expectShellHeldRowStaysOpenAfterRestart(
|
||||
server: AgentHookServer,
|
||||
childEvents: Record<string, unknown>[]
|
||||
): Promise<void> {
|
||||
const restarted = await restart(server)
|
||||
try {
|
||||
for (const event of childEvents) {
|
||||
await post(restarted, event)
|
||||
}
|
||||
|
||||
expect(row(restarted)?.state).toBe('working')
|
||||
} finally {
|
||||
restarted.stop()
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps the shell fact when OSC repaints a shell-held row', async () => {
|
||||
const server = await startServer()
|
||||
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'build in background' })
|
||||
await post(server, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] })
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
connectionId: null,
|
||||
payload: { state: 'working', prompt: 'title text', agentType: 'claude' }
|
||||
})
|
||||
expect(row(server)).toMatchObject({ state: 'working', prompt: 'title text' })
|
||||
|
||||
await expectShellHeldRowStaysOpenAfterRestart(server, [
|
||||
{ hook_event_name: 'SubagentStart', agent_id: 'achild-b' },
|
||||
{ hook_event_name: 'SubagentStop', agent_id: 'achild-b' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the shell fact when an answered child question is inferred', async () => {
|
||||
const server = await startServer()
|
||||
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' })
|
||||
await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-a' })
|
||||
await post(server, {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [{ id: 'achild-a', type: 'subagent', status: 'running' }, RUNNING_SHELL]
|
||||
})
|
||||
await post(server, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
agent_id: 'achild-a',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_use_id: 'toolu-question'
|
||||
})
|
||||
const waiting = row(server)
|
||||
expect(waiting).toMatchObject({ state: 'waiting', toolName: 'AskUserQuestion' })
|
||||
expect(
|
||||
server.inferQuestionAnswered({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: waiting?.receivedAt ?? 0,
|
||||
baselineStateStartedAt: waiting?.stateStartedAt ?? 0,
|
||||
baselinePrompt: waiting?.prompt ?? '',
|
||||
baselineAgentType: 'claude'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'done' } })
|
||||
|
||||
await expectShellHeldRowStaysOpenAfterRestart(server, [
|
||||
{ hook_event_name: 'SubagentStop', agent_id: 'achild-a' }
|
||||
])
|
||||
})
|
||||
})
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHookServer, _internals } from '../../agent-hook-server'
|
||||
import { buildBody, postHookEvent } from '../server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/client', () => ({
|
||||
track: trackMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: getCohortAtEmitMock
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Last-status persistence', () => {
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-laststatus-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('keeps a legacy Stop unconfirmed when only a restored child gates it', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'a0000000000000013',
|
||||
agent_type: 'reviewer'
|
||||
})
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
await postHookEvent(server, buildBody({ hook_event_name: 'Stop' }))
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: [expect.objectContaining({ id: 'a0000000000000013' })]
|
||||
})
|
||||
expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for a lead boundary after a post-restart idle matches a restored teammate', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'areviewer-6d3cb5b5',
|
||||
agent_type: 'reviewer'
|
||||
})
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
const restored = server.getStatusSnapshot()[0]
|
||||
expect(restored).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5' })]
|
||||
})
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'idle' })]
|
||||
})
|
||||
expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [{ id: 'treviewer', type: 'teammate', status: 'running' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'idle' })]
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,190 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
import type { SessionFileCandidate } from '../session-scanner-types'
|
||||
|
||||
const STALLED_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\a.jsonl'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ open: vi.fn(), readdir: vi.fn() }))
|
||||
|
||||
// Claude also counts sibling subagents; mock readdir to avoid real UNC access under fake timers.
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeFsPromisesModule>()),
|
||||
open: mocks.open,
|
||||
readdir: mocks.readdir
|
||||
}))
|
||||
|
||||
import * as transcriptFs from '../../native-chat/transcript-filesystem/wsl-transcript-fs-access'
|
||||
import { parseAgentSessionFileCached } from '../parse-cache/session-scanner-parse-cache'
|
||||
import {
|
||||
registerTranscriptConsumer,
|
||||
resetTranscriptConsumersForTests
|
||||
} from '../transcripts/session-transcript-consumers'
|
||||
import { resetSessionParseCacheForTests } from '../parse-cache/session-parse-cache-store'
|
||||
import {
|
||||
resetWslTranscriptFsGateForTests,
|
||||
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS
|
||||
} from '../../native-chat/transcript-filesystem/wsl-transcript-fs-gate'
|
||||
import { WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS } from '../../native-chat/transcript-filesystem/wsl-transcript-fs-route-quarantine'
|
||||
import { WslTranscriptFsError } from '../../native-chat/transcript-filesystem/wsl-transcript-fs-error'
|
||||
|
||||
type ReadResult = { bytesRead: number; buffer: Buffer }
|
||||
|
||||
// Settle stalled calls between cases to release their scan permits.
|
||||
let releaseStall: (() => void) | undefined
|
||||
|
||||
function stalls(): Promise<ReadResult> {
|
||||
return new Promise<ReadResult>((resolve) => {
|
||||
releaseStall = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) })
|
||||
})
|
||||
}
|
||||
|
||||
function servingHandle(body: Buffer) {
|
||||
return {
|
||||
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
const slice = body.subarray(position, Math.min(position + length, body.length))
|
||||
slice.copy(buffer, offset)
|
||||
return { bytesRead: slice.length, buffer }
|
||||
}),
|
||||
close: vi.fn(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
function record(index: number, text: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
||||
timestamp: new Date(1740000000000 + index * 60_000).toISOString(),
|
||||
cwd: '/repo/app',
|
||||
gitBranch: 'main',
|
||||
message: { role: 'user', content: text }
|
||||
})
|
||||
}
|
||||
|
||||
function body(count: number): Buffer {
|
||||
return Buffer.from(
|
||||
Array.from({ length: count }, (_, index) => `${record(index, `line ${index}`)}\n`).join('')
|
||||
)
|
||||
}
|
||||
|
||||
function candidate(path: string, bytes: Buffer, mtimeMs: number): SessionFileCandidate {
|
||||
return {
|
||||
agent: 'claude',
|
||||
file: {
|
||||
path,
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString(),
|
||||
sizeBytes: bytes.length
|
||||
},
|
||||
codexHome: null
|
||||
}
|
||||
}
|
||||
|
||||
// Late results cannot clear quarantine; advance through backoff before retrying.
|
||||
async function releaseAndSettle(): Promise<void> {
|
||||
releaseStall?.()
|
||||
releaseStall = undefined
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_ROUTE_QUARANTINE_BASE_MS)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetWslTranscriptFsGateForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
resetTranscriptConsumersForTests()
|
||||
mocks.open.mockReset()
|
||||
mocks.readdir.mockReset()
|
||||
mocks.readdir.mockResolvedValue([])
|
||||
releaseStall = undefined
|
||||
// performance.now drives the route quarantine clock, so it must be faked too.
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await releaseAndSettle()
|
||||
resetTranscriptConsumersForTests()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('AI Vault session parse with a stalled WSL transcript body read', () => {
|
||||
it('cancels stalled EOF finalization, cleans up, and admits the next read', async () => {
|
||||
const bytes = body(2)
|
||||
const file = candidate(STALLED_PATH.replace('a.jsonl', 'finalization.jsonl'), bytes, 1)
|
||||
mocks.open.mockResolvedValue(servingHandle(bytes))
|
||||
let entered!: () => void
|
||||
const finalizing = new Promise<void>((resolve) => {
|
||||
entered = resolve
|
||||
})
|
||||
const readdir = vi.spyOn(transcriptFs, 'wslGatedReaddir').mockImplementationOnce(() => {
|
||||
entered()
|
||||
return new Promise(() => {})
|
||||
})
|
||||
const finishes = vi.fn()
|
||||
registerTranscriptConsumer({
|
||||
beginRead: () => ({ message: () => {}, finish: finishes })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const first = parseAgentSessionFileCached(
|
||||
file,
|
||||
'linux',
|
||||
undefined,
|
||||
undefined,
|
||||
controller.signal
|
||||
)
|
||||
const cancelled = first.catch((error) => {
|
||||
expect(error).toMatchObject({
|
||||
name: 'AbortError',
|
||||
message: 'Agent Session History scan was cancelled'
|
||||
})
|
||||
return 'cancelled'
|
||||
})
|
||||
await finalizing
|
||||
let nextMessageCount: number | undefined
|
||||
const next = parseAgentSessionFileCached(file, 'linux').then((session) => {
|
||||
nextMessageCount = session?.messageCount
|
||||
return session
|
||||
})
|
||||
let result: string | undefined
|
||||
void cancelled.then((value) => {
|
||||
result = typeof value === 'string' ? value : 'completed'
|
||||
})
|
||||
controller.abort()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(result).toBe('cancelled')
|
||||
expect(finishes).toHaveBeenNthCalledWith(1, expect.objectContaining({ cancelled: true }))
|
||||
expect(nextMessageCount).toBe(2)
|
||||
await next
|
||||
expect(finishes).toHaveBeenCalledTimes(2)
|
||||
readdir.mockRestore()
|
||||
})
|
||||
|
||||
it('caches no resume point when the newline probe is refused mid-append', async () => {
|
||||
const first = body(2)
|
||||
mocks.open.mockResolvedValue(servingHandle(first))
|
||||
const seeded = await parseAgentSessionFileCached(candidate(STALLED_PATH, first, 1), 'linux')
|
||||
expect(seeded?.messageCount).toBe(2)
|
||||
|
||||
const grown = body(3)
|
||||
const serving = servingHandle(grown)
|
||||
mocks.open.mockResolvedValue({
|
||||
// Only the 1-byte resume probe stalls; the body read stays healthy.
|
||||
read: vi.fn((buffer: Buffer, offset: number, length: number, position: number) =>
|
||||
length === 1 ? stalls() : serving.read(buffer, offset, length, position)
|
||||
),
|
||||
close: vi.fn(async () => {})
|
||||
})
|
||||
const refused = expect(
|
||||
parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
|
||||
).rejects.toBeInstanceOf(WslTranscriptFsError)
|
||||
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
|
||||
await refused
|
||||
|
||||
await releaseAndSettle()
|
||||
mocks.open.mockResolvedValue(servingHandle(grown))
|
||||
const recovered = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
|
||||
|
||||
resetSessionParseCacheForTests()
|
||||
const cold = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
|
||||
expect(recovered).toEqual(cold)
|
||||
expect(recovered?.messageCount).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -1,210 +0,0 @@
|
||||
import { chmod, mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from '../session-scanner'
|
||||
import { isolatedScanRoots } from '../scanner-fixtures/session-scanner-test-fixtures'
|
||||
import type { AiVaultScanOptions } from '../session-scanner-types'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
function scopedScanOptions(claudeProjectsDir: string, extra: Partial<AiVaultScanOptions>) {
|
||||
return {
|
||||
...isolatedScanRoots(claudeProjectsDir),
|
||||
claudeProjectsDir,
|
||||
...extra
|
||||
} satisfies AiVaultScanOptions
|
||||
}
|
||||
|
||||
// Recent out-of-scope sessions fill the recency pass, so the old session can only come from the scope pass.
|
||||
async function writeOldClaudeSessionBehindRecentOnes(
|
||||
claudeRoot: string,
|
||||
old: { dirName: string; cwd: string }
|
||||
): Promise<void> {
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
...old,
|
||||
sessionId: 'old-in-scope',
|
||||
iso: '2026-01-01T00:00:00.000Z'
|
||||
})
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: `-other-${index}`,
|
||||
sessionId: `recent-${index}`,
|
||||
cwd: `/other/${index}`,
|
||||
iso: `2026-06-2${index}T00:00:00.000Z`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const LONG_PATH = `/repo/${'nested-folder/'.repeat(16)}app`
|
||||
|
||||
async function writeClaudeSession(args: {
|
||||
claudeRoot: string
|
||||
dirName: string
|
||||
sessionId: string
|
||||
cwd: string
|
||||
iso: string
|
||||
leadingCwdlessLine?: boolean
|
||||
}): Promise<void> {
|
||||
const dir = join(args.claudeRoot, args.dirName)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const records: unknown[] = []
|
||||
if (args.leadingCwdlessLine) {
|
||||
records.push({ type: 'last-prompt', sessionId: args.sessionId })
|
||||
}
|
||||
records.push({
|
||||
type: 'user',
|
||||
sessionId: args.sessionId,
|
||||
timestamp: args.iso,
|
||||
cwd: args.cwd,
|
||||
message: { role: 'user', content: `work in ${args.cwd}` }
|
||||
})
|
||||
const filePath = join(dir, `${args.sessionId}.jsonl`)
|
||||
await writeFile(filePath, records.map((record) => JSON.stringify(record)).join('\n'))
|
||||
const time = new Date(args.iso)
|
||||
await utimes(filePath, time, time)
|
||||
}
|
||||
|
||||
describe('scanAiVaultSessions scope inclusion', () => {
|
||||
it('surfaces in-scope sessions older than the global recency cap', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
|
||||
tempRoots.push(root)
|
||||
const claudeRoot = join(root, 'claude-projects')
|
||||
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: '-repo-app',
|
||||
sessionId: 'old-in-scope',
|
||||
cwd: '/repo/app',
|
||||
iso: '2026-01-01T00:00:00.000Z',
|
||||
leadingCwdlessLine: true
|
||||
})
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: '-repo-app-packages-ui',
|
||||
sessionId: 'old-in-scope-subdir',
|
||||
cwd: '/repo/app/packages/ui',
|
||||
iso: '2026-01-02T00:00:00.000Z'
|
||||
})
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: `-other-${index}`,
|
||||
sessionId: `recent-${index}`,
|
||||
cwd: `/other/${index}`,
|
||||
iso: `2026-06-2${index}T00:00:00.000Z`
|
||||
})
|
||||
}
|
||||
|
||||
const withoutScope = await scanAiVaultSessions(scopedScanOptions(claudeRoot, { limit: 2 }))
|
||||
const withScope = await scanAiVaultSessions(
|
||||
scopedScanOptions(claudeRoot, { limit: 2, scopePaths: ['/repo/app'] })
|
||||
)
|
||||
|
||||
const ids = (result: { sessions: { sessionId: string }[] }) =>
|
||||
result.sessions.map((session) => session.sessionId)
|
||||
|
||||
expect(ids(withoutScope)).not.toContain('old-in-scope')
|
||||
expect(ids(withoutScope)).not.toContain('old-in-scope-subdir')
|
||||
expect(ids(withScope)).toContain('old-in-scope')
|
||||
expect(ids(withScope)).toContain('old-in-scope-subdir')
|
||||
})
|
||||
|
||||
it('does not assign a native transcript to a WSL distro from cwd alone', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
|
||||
tempRoots.push(root)
|
||||
const claudeRoot = join(root, 'claude-projects')
|
||||
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: '-home-ada-repo',
|
||||
sessionId: 'old-wsl-in-scope',
|
||||
cwd: '/home/ada/repo',
|
||||
iso: '2026-01-01T00:00:00.000Z'
|
||||
})
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: `-other-wsl-${index}`,
|
||||
sessionId: `recent-wsl-${index}`,
|
||||
cwd: `/other/wsl/${index}`,
|
||||
iso: `2026-06-2${index}T00:00:00.000Z`
|
||||
})
|
||||
}
|
||||
|
||||
const result = await scanAiVaultSessions(
|
||||
scopedScanOptions(claudeRoot, {
|
||||
limit: 2,
|
||||
scopePaths: ['\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo']
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.sessions.map((session) => session.sessionId)).not.toContain('old-wsl-in-scope')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ layout: 'a drive root', scopePath: 'C:\\', dirName: 'C--repo', cwd: 'C:\\repo' },
|
||||
{
|
||||
layout: 'a Windows path in another case',
|
||||
scopePath: 'c:\\users\\ada\\repo',
|
||||
dirName: 'C--Users-Ada-repo',
|
||||
cwd: 'C:\\Users\\Ada\\repo'
|
||||
},
|
||||
{
|
||||
layout: 'the other WSL share spelling',
|
||||
scopePath: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo',
|
||||
dirName: '--wsl--Ubuntu-home-ada-repo',
|
||||
cwd: '\\\\wsl$\\Ubuntu\\home\\ada\\repo'
|
||||
},
|
||||
{
|
||||
// Claude Code names a bucket over 200 characters `<first 200>-<hash>`.
|
||||
layout: 'a long path',
|
||||
scopePath: LONG_PATH,
|
||||
dirName: `${LONG_PATH.replace(/[^a-zA-Z0-9]/g, '-').slice(0, 200)}-1a2b3c`,
|
||||
cwd: LONG_PATH
|
||||
}
|
||||
])('finds older in-scope Claude sessions for $layout', async ({ scopePath, dirName, cwd }) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
|
||||
tempRoots.push(root)
|
||||
const claudeRoot = join(root, 'claude-projects')
|
||||
await writeOldClaudeSessionBehindRecentOnes(claudeRoot, { dirName, cwd })
|
||||
|
||||
const result = await scanAiVaultSessions(
|
||||
scopedScanOptions(claudeRoot, { limit: 2, scopePaths: [scopePath] })
|
||||
)
|
||||
|
||||
expect(result.sessions.map((session) => session.sessionId)).toContain('old-in-scope')
|
||||
})
|
||||
|
||||
// chmod cannot lock out root, and Windows ignores the mode bits.
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
'reports an unreadable in-scope folder instead of listing it as empty',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
|
||||
tempRoots.push(root)
|
||||
const claudeRoot = join(root, 'claude-projects')
|
||||
await writeOldClaudeSessionBehindRecentOnes(claudeRoot, {
|
||||
dirName: '-repo-app',
|
||||
cwd: '/repo/app'
|
||||
})
|
||||
const bucket = join(claudeRoot, '-repo-app')
|
||||
await chmod(bucket, 0o000)
|
||||
try {
|
||||
const result = await scanAiVaultSessions(
|
||||
scopedScanOptions(claudeRoot, { limit: 2, scopePaths: ['/repo/app'] })
|
||||
)
|
||||
|
||||
expect(result.issues.map((issue) => issue.path)).toContain(bucket)
|
||||
} finally {
|
||||
await chmod(bucket, 0o755)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,144 +0,0 @@
|
||||
import { closeTestStores, createStore, testState } from '../persistence/persistence-test-harness'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { AutomationService } from './automation-service'
|
||||
import { TICK_MS } from './testing/automation-service-tick.test-fixture'
|
||||
import { createAwakeClock, type AwakeClock } from '../power/awake-clock'
|
||||
import {
|
||||
AUTOMATION_WAKE_SETTLE_MS,
|
||||
automationSystemSettled
|
||||
} from './dispatch/automation-wake-settle'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => testState.dir
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length)
|
||||
}
|
||||
}))
|
||||
|
||||
const makeRepo = (): Repo => ({
|
||||
id: 'r1',
|
||||
path: '/repo',
|
||||
displayName: 'test',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1
|
||||
})
|
||||
|
||||
type CounterModel = 'counts sleep (macOS/Windows Node)' | 'stops in sleep (Linux)'
|
||||
|
||||
describe.each<CounterModel>(['counts sleep (macOS/Windows Node)', 'stops in sleep (Linux)'])(
|
||||
'scheduled catch-up after a wake, on a monotonic counter that %s',
|
||||
(model) => {
|
||||
let clock: AwakeClock
|
||||
/** Sleep the fake monotonic counter missed: fake performance only moves with timers. */
|
||||
let countedSleep = 0
|
||||
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-automations-wake-test-'))
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
'Date',
|
||||
'performance',
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
'setInterval',
|
||||
'clearInterval',
|
||||
'setImmediate',
|
||||
'clearImmediate'
|
||||
]
|
||||
})
|
||||
// Why: offset-less times are local, matching the schedule's local BYHOUR in any CI TZ.
|
||||
vi.setSystemTime(new Date('2026-11-09T08:00:00'))
|
||||
countedSleep = 0
|
||||
clock = createAwakeClock({
|
||||
wallNow: () => Date.now(),
|
||||
monotonicNow: () => performance.now() + countedSleep,
|
||||
after: (beat, ms) => {
|
||||
setTimeout(beat, ms)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await closeTestStores()
|
||||
vi.useRealTimers()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function awakeFor(ms: number): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(ms)
|
||||
}
|
||||
|
||||
/** Asleep: no timers run; the wall clock moves on, and so does the counter if it counts sleep. */
|
||||
function asleepUntil(when: string): void {
|
||||
const ms = new Date(when).getTime() - Date.now()
|
||||
vi.setSystemTime(Date.now() + ms)
|
||||
if (model === 'counts sleep (macOS/Windows Node)') {
|
||||
countedSleep += ms
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduledService(opts: { tickMs?: number; graceMinutes?: number } = {}) {
|
||||
const tickMs = opts.tickMs ?? TICK_MS
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'GitHub i18n issue and PR scan',
|
||||
prompt: 'Scan',
|
||||
agentId: 'codex',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'r1::wt1',
|
||||
timezone: 'America/Los_Angeles',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-11-08T00:00:00').getTime(),
|
||||
missedRunGraceMinutes: opts.graceMinutes ?? 720
|
||||
})
|
||||
const launch = vi.fn(async () => ({ workspaceId: 'wt-1', terminalSessionId: 'tab-1' }))
|
||||
const service = new AutomationService(store, {
|
||||
tickMs,
|
||||
terminalLauncher: { launch, available: () => true }
|
||||
})
|
||||
service.wakeGate.settled = () => automationSystemSettled(clock)
|
||||
service.start()
|
||||
await awakeFor(tickMs)
|
||||
const statuses = () => store.listAutomationRuns(automation.id).map((run) => run.status)
|
||||
return { store, automation, launch, service, statuses }
|
||||
}
|
||||
|
||||
it('does not start a missed run during a dark wake, then catches it up once awake and stable', async () => {
|
||||
// A one-second tick runs the scheduler inside the two-second dark wake.
|
||||
const { store, automation, launch, service, statuses } = await scheduledService({
|
||||
tickMs: 1_000
|
||||
})
|
||||
|
||||
// The machine sleeps through 09:00 and dark-wakes at 09:08:30 with no resume event.
|
||||
clock.suspend()
|
||||
asleepUntil('2026-11-09T09:08:30')
|
||||
await awakeFor(2_000)
|
||||
expect(statuses()).toEqual([])
|
||||
|
||||
// Back to sleep, then a full wake at 09:19:37.
|
||||
asleepUntil('2026-11-09T09:19:37')
|
||||
clock.resume()
|
||||
await awakeFor(AUTOMATION_WAKE_SETTLE_MS - 1_000)
|
||||
expect(statuses()).toEqual([])
|
||||
expect(launch).not.toHaveBeenCalled()
|
||||
|
||||
await awakeFor(2_000)
|
||||
await vi.waitFor(() => expect(statuses()).toEqual(['dispatched']))
|
||||
expect(store.listAutomationRuns(automation.id)[0]!.scheduledFor).toBe(
|
||||
new Date('2026-11-09T09:00:00').getTime()
|
||||
)
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
service.stop()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ExternalAutomationManagerCache } from './external-automation-manager-cache'
|
||||
import { ExternalAutomationProbeCancelledError } from './external-automation-probe-scheduler'
|
||||
import type { ExternalAutomationManager } from '../../../shared/automations/external/external-automation'
|
||||
|
||||
function manager(id: string): ExternalAutomationManager {
|
||||
return {
|
||||
id,
|
||||
provider: 'openclaw',
|
||||
label: id,
|
||||
targetLabel: 'this computer',
|
||||
target: { type: 'local' },
|
||||
status: 'available',
|
||||
error: null,
|
||||
canManage: true,
|
||||
jobs: []
|
||||
}
|
||||
}
|
||||
|
||||
const sshOwner = 'owner:desktop:ssh:target-a:3'
|
||||
|
||||
describe('ExternalAutomationManagerCache', () => {
|
||||
it('propagates cancellation and leaves the previous entry untouched', async () => {
|
||||
const cache = new ExternalAutomationManagerCache()
|
||||
const key = { ownerKey: sshOwner, provider: 'openclaw' }
|
||||
cache.write(key, manager('openclaw:ssh:target-a'))
|
||||
|
||||
await expect(
|
||||
cache.resolve(key, () => Promise.reject(new ExternalAutomationProbeCancelledError()), {
|
||||
refresh: true
|
||||
})
|
||||
).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError)
|
||||
expect(cache.read(key)?.manager?.id).toBe('openclaw:ssh:target-a')
|
||||
})
|
||||
})
|
||||
@@ -1,141 +0,0 @@
|
||||
import { closeTestStores, createStore, testState } from '../../persistence/persistence-test-harness'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import type { Automation } from '../../../shared/automations/automation-definition'
|
||||
import type { AutomationRun } from '../../../shared/automations/history/automation-run'
|
||||
import { AutomationService } from '../automation-service'
|
||||
import type {
|
||||
AutomationRunCompletionObservation,
|
||||
AutomationRunTerminalObserver
|
||||
} from './run-completion-watcher'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
ipcMain: { handle: () => {} },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length)
|
||||
}
|
||||
}))
|
||||
|
||||
type TestStore = Awaited<ReturnType<typeof createStore>>
|
||||
|
||||
const makeRepo = (): Repo => ({
|
||||
id: 'r1',
|
||||
path: '/repo',
|
||||
displayName: 'test',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1
|
||||
})
|
||||
|
||||
const LAUNCH_TARGET = {
|
||||
workspaceId: 'wt1',
|
||||
terminalSessionId: 'tab-1',
|
||||
terminalPaneKey: 'tab-1:11111111-2222-4333-8444-555555555555',
|
||||
terminalPtyId: 'pty-1'
|
||||
}
|
||||
|
||||
const OPEN_LAUNCHER = { launch: async () => ({ ...LAUNCH_TARGET }), available: () => true }
|
||||
|
||||
function createAutomation(store: TestStore): Automation {
|
||||
store.addRepo(makeRepo())
|
||||
return store.createAutomation({
|
||||
name: 'Nightly check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-12T00:00:00Z').getTime()
|
||||
})
|
||||
}
|
||||
|
||||
function retainDispatchedRun(store: TestStore, automation: Automation): AutomationRun {
|
||||
const run = store.createAutomationRun(automation, Date.now() - 60_000, 'manual')
|
||||
return store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatched',
|
||||
...LAUNCH_TARGET,
|
||||
error: null
|
||||
})
|
||||
}
|
||||
|
||||
function readRun(store: TestStore, automationId: string, runId: string): AutomationRun {
|
||||
const run = store.listAutomationRuns(automationId).find((entry) => entry.id === runId)
|
||||
if (!run) {
|
||||
throw new Error('run missing')
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
function createPaneSurface(): {
|
||||
observer: AutomationRunTerminalObserver
|
||||
mountPane: () => void
|
||||
settleObservation: (value: AutomationRunCompletionObservation) => void
|
||||
observedHandles: string[]
|
||||
} {
|
||||
const state = { mounted: false }
|
||||
const observedHandles: string[] = []
|
||||
let resolveObservation: ((value: AutomationRunCompletionObservation) => void) | null = null
|
||||
return {
|
||||
observer: {
|
||||
resolveRunTerminal: (run) => (state.mounted && run.terminalPaneKey ? 'handle-1' : null),
|
||||
observeCompletion: (handle) => {
|
||||
observedHandles.push(handle)
|
||||
return new Promise<AutomationRunCompletionObservation>((resolve) => {
|
||||
resolveObservation = resolve
|
||||
})
|
||||
}
|
||||
},
|
||||
mountPane: () => {
|
||||
state.mounted = true
|
||||
},
|
||||
settleObservation: (value) => resolveObservation?.(value),
|
||||
observedHandles
|
||||
}
|
||||
}
|
||||
|
||||
describe('reconciling retained runs against a graph that has not published yet', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-retained-run-'))
|
||||
vi.useFakeTimers()
|
||||
// Away from the automation's 09:00 UTC occurrence so timer advances stay inert.
|
||||
vi.setSystemTime(new Date('2026-06-01T12:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await closeTestStores()
|
||||
vi.useRealTimers()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('re-attaches to the run once the restored pane mounts seconds later', async () => {
|
||||
const store = await createStore()
|
||||
const automation = createAutomation(store)
|
||||
const retained = retainDispatchedRun(store, automation)
|
||||
const surface = createPaneSurface()
|
||||
const service = new AutomationService(store, {
|
||||
terminalObserver: surface.observer,
|
||||
terminalLauncher: OPEN_LAUNCHER
|
||||
})
|
||||
|
||||
service.start()
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(surface.observedHandles).toEqual([])
|
||||
|
||||
surface.mountPane()
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(surface.observedHandles).toEqual(['handle-1'])
|
||||
|
||||
surface.settleObservation({ status: 'completed', error: null })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(readRun(store, automation.id, retained.id).status).toBe('completed')
|
||||
service.stop()
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const browserMocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
openPopupWithOriginBarMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { prependListener: vi.fn(), off: vi.fn(), getPath: browserMocks.appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: browserMocks.shellOpenExternalMock },
|
||||
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
|
||||
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
|
||||
webContents: { fromId: browserMocks.webContentsFromIdMock }
|
||||
}))
|
||||
|
||||
import { browserManager } from '../../guest-lifecycle/manager/browser-manager'
|
||||
import { runWithActiveRpcCaller } from '../../../runtime/rpc/callers/active-call'
|
||||
import { withAgentInputInjection } from '../../cdp/input/agent-input-injection'
|
||||
import {
|
||||
guestNavigationMethods,
|
||||
rendererWebContentsId,
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
} from '../../guest-lifecycle/manager/browser-manager-test-harness'
|
||||
import type { BrowserExtensionCaller } from '../api-runtime/browser-extension-namespace-registry'
|
||||
import { placeBrowserExtensionCallerTab } from './browser-extension-agent-caller'
|
||||
|
||||
const workSession = { name: 'work' }
|
||||
const otherSession = { name: 'other' }
|
||||
|
||||
/** Registers a guest page; `click` delivers an activating input to it. */
|
||||
function registerPage(browserPageId: string, id: number, worktreeId: string) {
|
||||
const on = vi.fn()
|
||||
const guest = {
|
||||
...guestNavigationMethods(),
|
||||
id,
|
||||
session: workSession,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isCurrentlyAudible: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setIgnoreMenuShortcuts: vi.fn(),
|
||||
on,
|
||||
prependListener: vi.fn(),
|
||||
off: vi.fn(),
|
||||
openDevTools: vi.fn()
|
||||
}
|
||||
const previous = browserMocks.webContentsFromIdMock.getMockImplementation()
|
||||
browserMocks.webContentsFromIdMock.mockImplementation((lookup: number) =>
|
||||
lookup === id ? guest : previous?.(lookup)
|
||||
)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the manager and input tracker call only the methods stubbed above.
|
||||
const contents = guest as unknown as Electron.WebContents
|
||||
browserManager.attachGuestPolicies(contents)
|
||||
browserManager.registerGuest({
|
||||
browserPageId,
|
||||
worktreeId,
|
||||
webContentsId: id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const onInput = on.mock.calls.find(([event]) => event === 'input-event')?.[1]
|
||||
return { contents, click: () => onInput({}, { type: 'mouseUp' }) }
|
||||
}
|
||||
|
||||
function caller(session: typeof workSession, extra: Partial<BrowserExtensionCaller> = {}) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: placement reads session, webContents and userGesture only.
|
||||
return { session, browserProfileId: 'work', fromWorker: true, ...extra } as BrowserExtensionCaller
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
browserMocks.webContentsFromIdMock.mockImplementation(() => null)
|
||||
})
|
||||
|
||||
describe('an extension tab set off while an agent drives a page in its profile', () => {
|
||||
it("opens in the background in the agent's workspace, unless the user set it off", async () => {
|
||||
const userPage = registerPage('user-page', 11, 'wt-user')
|
||||
const agentPage = registerPage('agent-page', 12, 'wt-agent')
|
||||
const command = runWithActiveRpcCaller({ kind: 'local-cli' }, () =>
|
||||
browserManager.beginAgentCommand('agent-page')
|
||||
)
|
||||
const placeFromWorker = () => placeBrowserExtensionCallerTab(caller(workSession), true)
|
||||
const agentPlacement = { active: false, worktreeId: 'wt-agent' }
|
||||
|
||||
expect(placeFromWorker()).toEqual(agentPlacement)
|
||||
expect(placeBrowserExtensionCallerTab(caller(otherSession), true)).toEqual({ active: true })
|
||||
expect(
|
||||
placeBrowserExtensionCallerTab(caller(workSession, { userGesture: true }), true)
|
||||
).toEqual({ active: true })
|
||||
|
||||
userPage.click()
|
||||
expect(placeFromWorker()).toEqual({ active: true })
|
||||
await withAgentInputInjection(agentPage.contents, async () => agentPage.click())
|
||||
expect(placeFromWorker()).toEqual(agentPlacement)
|
||||
|
||||
command.end()
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { build } from 'esbuild'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { JSONC_PARSER_ESM_ALIAS } from '../../../../../config/build-plugins/jsonc-parser-esm'
|
||||
import { describeClaudeProfile } from '../claude-profile-paths'
|
||||
import { runClaudeProfileSetupInWorker } from './claude-profile-setup-worker'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })))
|
||||
|
||||
it('runs account setup in the built worker entry, off the calling thread', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'claude-setup-worker-'))
|
||||
roots.push(root)
|
||||
const userHome = join(root, 'personal')
|
||||
const dataRoot = join(root, 'data')
|
||||
mkdirSync(join(userHome, '.claude'), { recursive: true })
|
||||
const profile = describeClaudeProfile(dataRoot, 'a', {
|
||||
executionHostId: 'local',
|
||||
runtime: 'host'
|
||||
})
|
||||
mkdirSync(profile.home, { recursive: true })
|
||||
const entry = join(root, 'claude-profile-setup-worker-entry.js')
|
||||
await build({
|
||||
entryPoints: [
|
||||
resolve('src/main/claude-accounts/profiles/setup/claude-profile-setup-worker-entry.ts')
|
||||
],
|
||||
outfile: entry,
|
||||
bundle: true,
|
||||
alias: JSONC_PARSER_ESM_ALIAS,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
external: ['electron'],
|
||||
logLevel: 'silent'
|
||||
})
|
||||
|
||||
const report = await runClaudeProfileSetupInWorker(
|
||||
{
|
||||
dataRoot,
|
||||
profile,
|
||||
userHome,
|
||||
userConfigDir: undefined,
|
||||
hooks: false,
|
||||
claudeVersion: undefined
|
||||
},
|
||||
entry
|
||||
)
|
||||
|
||||
expect(report.outcome).toBe('prepared')
|
||||
expect(existsSync(join(dataRoot, 'claude-profiles', 'a', 'profile.json'))).toBe(true)
|
||||
await expect(
|
||||
runClaudeProfileSetupInWorker(
|
||||
{
|
||||
dataRoot,
|
||||
profile,
|
||||
userHome,
|
||||
userConfigDir: undefined,
|
||||
hooks: false,
|
||||
claudeVersion: undefined
|
||||
},
|
||||
join(root, 'missing-entry.js')
|
||||
)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { vi, describe, expect, it } from 'vitest'
|
||||
import type * as GitBashModule from '../../pty/shell-startup/git-bash'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/userData'
|
||||
}
|
||||
}))
|
||||
|
||||
const { gitBashAvailableMock } = vi.hoisted(() => ({ gitBashAvailableMock: { value: true } }))
|
||||
vi.mock('../../pty/shell-startup/git-bash', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitBashModule>()),
|
||||
isGitBashAvailable: () => gitBashAvailableMock.value
|
||||
}))
|
||||
|
||||
import { ClaudeHookService } from '../hook-service'
|
||||
|
||||
describe('ClaudeHookService.install', () => {
|
||||
it('does not re-install a managed statusLine the user deleted, until remove() resets the opt-out', () => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-optout-'))
|
||||
vi.stubEnv('HOME', tmpHome)
|
||||
vi.stubEnv('USERPROFILE', tmpHome)
|
||||
try {
|
||||
const settingsPath = join(tmpHome, '.claude', 'settings.json')
|
||||
new ClaudeHookService().install()
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeTruthy()
|
||||
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
|
||||
delete settings.statusLine
|
||||
writeFileSync(settingsPath, JSON.stringify(settings))
|
||||
|
||||
new ClaudeHookService().install()
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined()
|
||||
|
||||
new ClaudeHookService().remove()
|
||||
new ClaudeHookService().install()
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeTruthy()
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getManagedStatusLineScript } from './statusline-script'
|
||||
|
||||
const ORIGINAL_PLATFORM = process.platform
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: ORIGINAL_PLATFORM })
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('statusline curl throttle (posix behavioral)', () => {
|
||||
const PANE_KEY = 'tab-1:00000000-0000-4000-8000-000000000000'
|
||||
const dirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function rateLimitPayload(durationMs: number): string {
|
||||
return JSON.stringify({
|
||||
cost: { total_duration_ms: durationMs },
|
||||
rate_limits: { five_hour: { used_percentage: 12 } }
|
||||
})
|
||||
}
|
||||
|
||||
function makeHarness(): {
|
||||
scriptPath: string
|
||||
dir: string
|
||||
curlLog: string
|
||||
payloadLog: string
|
||||
dateLog: string
|
||||
catLog: string
|
||||
} {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-statusline-throttle-'))
|
||||
dirs.push(dir)
|
||||
const curlLog = join(dir, 'curl.log')
|
||||
const payloadLog = join(dir, 'payload.log')
|
||||
const dateLog = join(dir, 'date.log')
|
||||
const catLog = join(dir, 'cat.log')
|
||||
const scriptPath = join(dir, 'statusline.sh')
|
||||
writeFileSync(scriptPath, getManagedStatusLineScript('posix'))
|
||||
const binDir = join(dir, 'stub-bin')
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(binDir, 'curl'),
|
||||
`#!/bin/sh\n/bin/cat > "${payloadLog}"\nprintf 'x\\n' >> "${curlLog}"\nexit 0\n`,
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
writeFileSync(
|
||||
join(binDir, 'date'),
|
||||
`#!/bin/sh\nprintf 'x\\n' >> "${dateLog}"\nprintf '2000000000\\n'\n`,
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
writeFileSync(join(binDir, 'cat'), `#!/bin/sh\nprintf 'x\\n' >> "${catLog}"\n/bin/cat "$@"\n`, {
|
||||
mode: 0o755
|
||||
})
|
||||
return { scriptPath, dir, curlLog, payloadLog, dateLog, catLog }
|
||||
}
|
||||
|
||||
function runScript(
|
||||
scriptPath: string,
|
||||
dir: string,
|
||||
payload: string,
|
||||
paneKey = PANE_KEY
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('sh', [scriptPath], {
|
||||
env: {
|
||||
PATH: `${join(dir, 'stub-bin')}:${process.env.PATH ?? ''}`,
|
||||
TMPDIR: dir,
|
||||
ORCA_AGENT_HOOK_PORT: '65535',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'test-token',
|
||||
ORCA_PANE_KEY: paneKey
|
||||
},
|
||||
stdio: ['pipe', 'ignore', 'pipe']
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
child.on('error', reject)
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`statusline script exited ${code}: ${stderr}`))
|
||||
}
|
||||
})
|
||||
child.stdin.write(payload)
|
||||
child.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
it('preserves multiline payloads while capturing stdin with shell builtins', async () => {
|
||||
const { scriptPath, dir, payloadLog } = makeHarness()
|
||||
const payload = JSON.stringify(JSON.parse(rateLimitPayload(1_000)), null, 2)
|
||||
await runScript(scriptPath, dir, payload)
|
||||
expect(readFileSync(payloadLog, 'utf8')).toBe(payload)
|
||||
})
|
||||
})
|
||||
@@ -1,197 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalItemBody } from '../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import { createClaudeJournalTranslator } from './journal/claude-structured-journal-translation'
|
||||
|
||||
function setup() {
|
||||
const rows = new Map<string, AgentJournalItemBody>()
|
||||
const translator = createClaudeJournalTranslator({
|
||||
sink: {
|
||||
appendItem: (identity, body) => {
|
||||
if (body.kind !== 'turn') {
|
||||
rows.set(agentJournalItemKey(identity), body)
|
||||
}
|
||||
},
|
||||
appendTombstone: vi.fn(),
|
||||
publish: vi.fn()
|
||||
}
|
||||
})
|
||||
const frame = (message: Record<string, unknown>, observedAt?: number): void =>
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
...(observedAt === undefined ? {} : { observedAt }),
|
||||
message: {
|
||||
session_id: 'session',
|
||||
parent_tool_use_id: null,
|
||||
...message
|
||||
}
|
||||
})
|
||||
const stream = (uuid: string, event: Record<string, unknown>, observedAt?: number): void =>
|
||||
frame({ type: 'stream_event', uuid, event }, observedAt)
|
||||
const final = (uuid: string, content: unknown[], observedAt?: number): void =>
|
||||
frame(
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid,
|
||||
message: { id: 'message-1', role: 'assistant', content }
|
||||
},
|
||||
observedAt
|
||||
)
|
||||
return { rows, translator, frame, stream, final }
|
||||
}
|
||||
|
||||
function reasoning(text: string, lifecycle: Record<string, unknown>): AgentJournalItemBody {
|
||||
return {
|
||||
kind: 'message',
|
||||
role: 'reasoning',
|
||||
blocks: [{ type: 'text', text }],
|
||||
...lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('structured Claude reasoning', () => {
|
||||
it('writes final-only thinking closed, with no span it never saw', () => {
|
||||
const { rows, final, translator } = setup()
|
||||
final('final-only', [{ type: 'thinking', thinking: 'Inspecting the request' }], 5_000)
|
||||
expect([...rows.values()]).toEqual([
|
||||
reasoning('Inspecting the request', { state: 'completed' })
|
||||
])
|
||||
translator.dispose()
|
||||
})
|
||||
|
||||
it('streams a running row and closes that same row on its final frame', () => {
|
||||
vi.useFakeTimers()
|
||||
const { rows, stream, final, translator } = setup()
|
||||
stream('start', { type: 'message_start', message: { id: 'message-1' } }, 1_000)
|
||||
stream(
|
||||
'thinking-start',
|
||||
{ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } },
|
||||
1_000
|
||||
)
|
||||
stream(
|
||||
'delta-1',
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'thinking_delta', thinking: 'Inspecting ' }
|
||||
},
|
||||
3_000
|
||||
)
|
||||
translator.flush()
|
||||
const firstKey = [...rows.keys()][0]
|
||||
expect(rows.get(firstKey!)).toEqual(reasoning('Inspecting ', { state: 'running' }))
|
||||
stream(
|
||||
'delta-2',
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'thinking_delta', thinking: 'the request' }
|
||||
},
|
||||
4_000
|
||||
)
|
||||
translator.flush()
|
||||
expect([...rows.keys()]).toEqual([firstKey])
|
||||
final('thinking-final', [{ type: 'thinking', thinking: 'Inspecting the request' }], 9_000)
|
||||
expect([...rows.keys()]).toEqual([firstKey])
|
||||
expect(rows.get(firstKey!)).toEqual(
|
||||
reasoning('Inspecting the request', { state: 'completed', completedAt: 9_000 })
|
||||
)
|
||||
|
||||
stream('text-start', {
|
||||
type: 'content_block_start',
|
||||
index: 1,
|
||||
content_block: { type: 'text', text: '' }
|
||||
})
|
||||
stream('text-delta', {
|
||||
type: 'content_block_delta',
|
||||
index: 1,
|
||||
delta: { type: 'text_delta', text: 'Here is the answer' }
|
||||
})
|
||||
translator.flush()
|
||||
final('text-final', [{ type: 'text', text: 'Here is the answer' }])
|
||||
expect([...rows.values()].map((body) => body.kind === 'message' && body.role)).toEqual([
|
||||
'reasoning',
|
||||
'assistant'
|
||||
])
|
||||
expect(translator.pendingStreamedBlocks).toBe(0)
|
||||
translator.dispose()
|
||||
})
|
||||
|
||||
it('closes a streamed row with its streamed text when the final frame carries none', () => {
|
||||
vi.useFakeTimers()
|
||||
const { rows, stream, final, translator } = setup()
|
||||
stream(
|
||||
'delta',
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'thinking_delta', thinking: 'Summary' }
|
||||
},
|
||||
1_000
|
||||
)
|
||||
final('thinking-final', [{ type: 'thinking', thinking: '', signature: 'sig' }], 2_000)
|
||||
expect([...rows.values()]).toEqual([
|
||||
reasoning('Summary', { state: 'completed', completedAt: 2_000 })
|
||||
])
|
||||
expect(translator.pendingStreamedBlocks).toBe(0)
|
||||
translator.dispose()
|
||||
})
|
||||
|
||||
it('reconciles an empty final block before the next thinking block', () => {
|
||||
const { rows, stream, final, translator } = setup()
|
||||
stream('empty-start', {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'thinking', thinking: '' }
|
||||
})
|
||||
final('empty-final', [{ type: 'thinking', thinking: '' }])
|
||||
stream('next-start', {
|
||||
type: 'content_block_start',
|
||||
index: 1,
|
||||
content_block: { type: 'thinking', thinking: 'Next thought' }
|
||||
})
|
||||
translator.flush()
|
||||
const key = [...rows.keys()][0]
|
||||
final('next-final', [{ type: 'thinking', thinking: 'Next thought' }])
|
||||
expect([...rows.keys()]).toEqual([key])
|
||||
expect(translator.pendingStreamedBlocks).toBe(0)
|
||||
translator.dispose()
|
||||
})
|
||||
|
||||
it('writes streamed thinking into the turn its first delta opened', () => {
|
||||
vi.useFakeTimers()
|
||||
const scopes: unknown[] = []
|
||||
const translator = createClaudeJournalTranslator({
|
||||
sink: {
|
||||
appendItem: (_identity, body, options) => {
|
||||
if (body.kind === 'message' && body.role === 'reasoning') {
|
||||
scopes.push(options.turnScope)
|
||||
}
|
||||
},
|
||||
appendTombstone: vi.fn(),
|
||||
publish: vi.fn()
|
||||
}
|
||||
})
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'stream_event',
|
||||
session_id: 'session',
|
||||
parent_tool_use_id: null,
|
||||
uuid: 'delta',
|
||||
event: {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'thinking_delta', thinking: 'Considering' }
|
||||
}
|
||||
}
|
||||
})
|
||||
translator.flush()
|
||||
expect(scopes).toEqual([{ kind: 'turn', turnItemId: expect.stringContaining('turn') }])
|
||||
translator.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalItemBody } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { AgentJournalItemIdentity } from '../../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
import type {
|
||||
StructuredAgentSessionEventSink,
|
||||
StructuredAgentSessionItemAppendOptions
|
||||
} from '../../../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
|
||||
function recorder() {
|
||||
const writes: {
|
||||
identity: AgentJournalItemIdentity
|
||||
body: AgentJournalItemBody
|
||||
options: StructuredAgentSessionItemAppendOptions
|
||||
}[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body, options) => writes.push({ identity, body, options }),
|
||||
appendTombstone: vi.fn(),
|
||||
publish: vi.fn()
|
||||
}
|
||||
const scopeOfProse = (text: string) =>
|
||||
writes.findLast(
|
||||
(write) =>
|
||||
write.body.kind === 'message' &&
|
||||
write.body.blocks.some((block) => block.type === 'text' && block.text === text)
|
||||
)?.options.turnScope
|
||||
return { sink, writes, scopeOfProse }
|
||||
}
|
||||
|
||||
function frame(message: Record<string, unknown>, startsTurn = false) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
...(startsTurn ? { startsTurn: true as const } : {}),
|
||||
message: { session_id: 'claude-session', ...message }
|
||||
}
|
||||
}
|
||||
|
||||
function prose(uuid: string, text: string, parentToolUseId: string | null = null) {
|
||||
return frame({
|
||||
type: 'assistant',
|
||||
uuid,
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text }] }
|
||||
})
|
||||
}
|
||||
|
||||
it("scopes the session's and a subagent's rows to the open turn, and a late one to none", () => {
|
||||
const { sink, writes, scopeOfProse } = recorder()
|
||||
const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' })
|
||||
translator.handle(
|
||||
frame(
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'user-1',
|
||||
parent_tool_use_id: null,
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'go' }] }
|
||||
},
|
||||
true
|
||||
)
|
||||
)
|
||||
translator.handle(prose('assistant-1', 'Working on it'))
|
||||
translator.handle(prose('child-1', 'Child mid-turn', 'toolu_1'))
|
||||
translator.handle(frame({ type: 'result', subtype: 'success', uuid: 'result-1', result: 'ok' }))
|
||||
translator.handle(prose('child-2', 'Child after the turn', 'toolu_1'))
|
||||
|
||||
const turnRecord = writes.find((write) => write.body.kind === 'turn')
|
||||
expect(turnRecord?.options.turnScope).toEqual({ kind: 'thread' })
|
||||
const turnKey = expect.stringContaining('turn-lifecycle')
|
||||
expect(scopeOfProse('Working on it')).toEqual({ kind: 'turn', turnItemId: turnKey })
|
||||
expect(scopeOfProse('Child mid-turn')).toEqual({ kind: 'turn', turnItemId: turnKey })
|
||||
expect(scopeOfProse('Child after the turn')).toEqual({ kind: 'thread' })
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalItemBody } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { AgentJournalItemIdentity } from '../../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
import type { StructuredAgentSessionEventSink } from '../../../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
|
||||
function sinkState() {
|
||||
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body) => items.push({ identity, body }),
|
||||
appendTombstone: () => {},
|
||||
publish: vi.fn()
|
||||
}
|
||||
return { sink, items }
|
||||
}
|
||||
|
||||
function providerFrameKinds(items: { body: AgentJournalItemBody }[]): string[] {
|
||||
return items.flatMap((item) =>
|
||||
item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : []
|
||||
)
|
||||
}
|
||||
|
||||
/** Shape reported from Claude Code 2.1.316; UUIDs are test identities. */
|
||||
function pluginUi(subtype: string | undefined, uuid: string, fields: Record<string, unknown>) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'system',
|
||||
subtype,
|
||||
plugin: 'fos-skills',
|
||||
...fields,
|
||||
uuid,
|
||||
session_id: 'claude-session'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function userTurn(uuid: string, text: string) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
startsTurn: true as const,
|
||||
message: {
|
||||
type: 'user',
|
||||
uuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
isReplay: true,
|
||||
message: { role: 'user', content: text }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assistantReply(uuid: string, text: string) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'assistant',
|
||||
uuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude plugin UI frames', () => {
|
||||
it('keeps plugin status lines and toasts off the transcript for a whole turn', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userTurn('user-1', 'Reply with exactly PROBE_OK and nothing else.'))
|
||||
translator.handle(pluginUi('ui_status', 'ui-1', { text: 'PR 6 · 진행 13 · 대기 6 /board' }))
|
||||
translator.handle(assistantReply('assistant-1', 'PROBE_OK'))
|
||||
// Captured from Claude Code 2.1.316 with an isolated test plugin.
|
||||
translator.handle(
|
||||
pluginUi('ui_toast', 'ui-2', {
|
||||
plugin: 'qa-plugin-ui',
|
||||
text: 'QA plugin toast: prompt seen',
|
||||
timeout_ms: 4000
|
||||
})
|
||||
)
|
||||
translator.handle(pluginUi('ui_status', 'ui-3', { text: null }))
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
uuid: 'result-1',
|
||||
session_id: 'claude-session',
|
||||
is_error: false,
|
||||
result: 'PROBE_OK',
|
||||
terminal_reason: 'completed'
|
||||
}
|
||||
})
|
||||
|
||||
expect(providerFrameKinds(state.items)).toEqual([])
|
||||
expect(
|
||||
state.items.flatMap((item) =>
|
||||
item.body.kind === 'message' && item.body.role === 'assistant' ? [item.body.blocks] : []
|
||||
)
|
||||
).toEqual([[{ type: 'text', text: 'PROBE_OK' }]])
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalItemIdentity } from '../../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
import type { AgentJournalProducerLinkage } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionAppendOptions } from '../../../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink'
|
||||
import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
|
||||
import type {
|
||||
ClaudeSubagentLinkageSource,
|
||||
ClaudeSubagentLinkageVerdict
|
||||
} from '../../subagents/claude-subagent-linkage'
|
||||
|
||||
function identityOf(uuid: string): AgentJournalItemIdentity {
|
||||
return { provider: 'claude', sessionId: 'claude-session', uuid }
|
||||
}
|
||||
|
||||
/** Scopeless blocks are root-owned and must never consult the child resolver. */
|
||||
const unconsultedProducer: ClaudeSubagentLinkageSource = {
|
||||
linkageFor: () => {
|
||||
throw new Error('resolver consulted for a block with no scope')
|
||||
},
|
||||
settledLinkageFor: () => {
|
||||
throw new Error('resolver consulted for a block with no scope')
|
||||
}
|
||||
}
|
||||
|
||||
const CHILD_LINKAGE: AgentJournalProducerLinkage = {
|
||||
agentId: 'task-1',
|
||||
providerParentRef: 'toolu_1',
|
||||
producerKind: 'agent'
|
||||
}
|
||||
|
||||
/** Resolve announcements mid-stream; use the raw reference if no announcement arrives. */
|
||||
function scriptedProducer() {
|
||||
let verdict: ClaudeSubagentLinkageVerdict = { kind: 'pending' }
|
||||
const settledFallback: Extract<ClaudeSubagentLinkageVerdict, { kind: 'linked' }> = {
|
||||
kind: 'linked',
|
||||
linkage: { agentId: 'toolu_1', providerParentRef: 'toolu_1', producerKind: 'agent' }
|
||||
}
|
||||
return {
|
||||
source: {
|
||||
linkageFor: () => verdict,
|
||||
settledLinkageFor: () => (verdict.kind === 'pending' ? settledFallback : verdict)
|
||||
} satisfies ClaudeSubagentLinkageSource,
|
||||
resolve: (linkage: AgentJournalProducerLinkage) => {
|
||||
verdict = { kind: 'linked', linkage }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkpoints(producer: ClaudeSubagentLinkageSource = unconsultedProducer) {
|
||||
const rows: { uuid: string; text: string }[] = []
|
||||
const stamps: StructuredAgentSessionAppendOptions[] = []
|
||||
let scheduled: (() => void) | null = null
|
||||
const store = createClaudeStreamedTextCheckpoints({
|
||||
producer,
|
||||
persist: (identity, text, options) => {
|
||||
rows.push({ uuid: 'uuid' in identity ? identity.uuid : '', text })
|
||||
stamps.push(options)
|
||||
},
|
||||
schedule: (run) => {
|
||||
scheduled = run
|
||||
return () => {
|
||||
scheduled = null
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
store,
|
||||
rows,
|
||||
stamps,
|
||||
runWindow: () => {
|
||||
const run = scheduled as (() => void) | null
|
||||
run?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('claude streamed text checkpoints', () => {
|
||||
it('writes a checkpoint at once while the producing agent is provisional', () => {
|
||||
const producer = scriptedProducer()
|
||||
const { store, rows, stamps, runWindow } = checkpoints(producer.source)
|
||||
|
||||
store.append(identityOf('block-1'), 'partial', 'toolu_1')
|
||||
runWindow()
|
||||
expect(rows).toEqual([{ uuid: 'block-1', text: 'partial' }])
|
||||
expect(stamps.at(-1)).toMatchObject({ agentId: 'toolu_1' })
|
||||
|
||||
// Flush advances text; reattribute corrects linkage even when text is unchanged.
|
||||
producer.resolve(CHILD_LINKAGE)
|
||||
store.flush()
|
||||
expect(stamps.at(-1)).toMatchObject({ agentId: 'toolu_1' })
|
||||
|
||||
store.reattribute()
|
||||
|
||||
expect(rows.at(-1)).toEqual({ uuid: 'block-1', text: 'partial' })
|
||||
expect(stamps.at(-1)).toEqual(CHILD_LINKAGE)
|
||||
})
|
||||
})
|
||||
@@ -1,216 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ClaudeStructuredSessionEvent } from '../claude-structured-session-state'
|
||||
import {
|
||||
PROVIDER_SESSION_ID,
|
||||
acquired,
|
||||
adapterFor,
|
||||
fakeClaude,
|
||||
identityFor
|
||||
} from '../claude-structured-session-test-support'
|
||||
import { claudeOpened } from '../claude-structured-startup-settled-test-support'
|
||||
|
||||
/** Claude Code 2.1.280 list_models fixture; default and opus[1m] resolve to the same model. */
|
||||
const CATALOG = [
|
||||
{
|
||||
value: 'default',
|
||||
resolvedModel: 'claude-opus-5-5[1m]',
|
||||
displayName: 'Default (recommended)',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
|
||||
},
|
||||
{
|
||||
value: 'opus[1m]',
|
||||
resolvedModel: 'claude-opus-5-5[1m]',
|
||||
displayName: 'Opus (1M context)',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
|
||||
},
|
||||
{
|
||||
value: 'sonnet',
|
||||
resolvedModel: 'claude-sonnet-5',
|
||||
displayName: 'Sonnet',
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
|
||||
},
|
||||
{ value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' }
|
||||
]
|
||||
|
||||
/** Claude Code 2.1.280 scratch-config responses: applied includes env resolution; effective reflects settings. */
|
||||
const SETTINGS = {
|
||||
noOverride: {
|
||||
effective: {},
|
||||
sources: [],
|
||||
applied: { model: 'claude-opus-5-5[1m]', effort: 'medium', advisor: null, ultracode: false }
|
||||
},
|
||||
settingsHaiku: {
|
||||
effective: { model: 'haiku' },
|
||||
sources: [{ source: 'userSettings', settings: { model: 'haiku' } }],
|
||||
applied: { model: 'claude-haiku-4-5-20251001', effort: null, advisor: null, ultracode: false }
|
||||
}
|
||||
}
|
||||
|
||||
function startedWithoutATurn(settings: unknown) {
|
||||
// The real startup proof before any turn is the SessionStart hook frame, which names no model.
|
||||
return fakeClaude({
|
||||
initProof: 'session-start',
|
||||
initModels: CATALOG,
|
||||
settings,
|
||||
routes: { list_models: () => CATALOG }
|
||||
})
|
||||
}
|
||||
|
||||
describe('Claude model before the first turn', () => {
|
||||
it('uses native applied effort despite settings High, before and after a reply', async () => {
|
||||
// Claude Code 2.1.318: no flag applies Medium; --effort low applies Low.
|
||||
const claude = startedWithoutATurn({
|
||||
effective: { effortLevel: 'high' },
|
||||
applied: { model: 'claude-opus-5-5', effort: 'medium', ultracode: false }
|
||||
})
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = await acquired(claude, {}, events)
|
||||
const current = async () =>
|
||||
(await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current
|
||||
expect((await current()).effort).toBe('medium')
|
||||
expect((await current()).confirmed ?? []).not.toContain('effort')
|
||||
const connection = claude.connections[0]!
|
||||
connection.getSettings = async () => ({
|
||||
effective: { effortLevel: 'high' },
|
||||
applied: { model: 'claude-opus-5-5', effort: 'low', ultracode: false }
|
||||
})
|
||||
connection.handlers.onMessage?.({
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
uuid: 'finished',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
is_error: false
|
||||
})
|
||||
await vi.waitFor(async () => expect((await current()).effort).toBe('low'))
|
||||
expect((await current()).confirmed ?? []).not.toContain('effort')
|
||||
const started = await claudeOpened(adapter, 'session-1')
|
||||
expect(started.reportedOptions).not.toHaveProperty('effort')
|
||||
const reported = events.find((event) => event.type === 'options-reported')
|
||||
expect(reported).toMatchObject({
|
||||
reportedOptions: expect.not.objectContaining({ effort: expect.anything() })
|
||||
})
|
||||
await adapter.closeAll()
|
||||
})
|
||||
|
||||
it('clears inferred effort when native settings explicitly report none', async () => {
|
||||
const claude = startedWithoutATurn({
|
||||
effective: { effortLevel: 'high' },
|
||||
applied: { model: 'claude-opus-5-5', effort: 'medium', ultracode: false }
|
||||
})
|
||||
const adapter = await acquired(claude)
|
||||
expect((await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current.effort).toBe(
|
||||
'medium'
|
||||
)
|
||||
// The recorded Haiku response explicitly names no effort parameter.
|
||||
claude.connections[0]!.getSettings = async () => ({
|
||||
...SETTINGS.settingsHaiku,
|
||||
effective: { model: 'haiku', effortLevel: 'high' }
|
||||
})
|
||||
claude.connections[0]!.handlers.onMessage?.({
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
uuid: 'haiku-finished',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
is_error: false
|
||||
})
|
||||
await vi.waitFor(async () =>
|
||||
expect(
|
||||
(await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current.effort
|
||||
).toBeUndefined()
|
||||
)
|
||||
await adapter.closeAll()
|
||||
})
|
||||
|
||||
it('lets the first turn confirm or correct it', async () => {
|
||||
const claude = startedWithoutATurn(SETTINGS.settingsHaiku)
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
claude.connections[0]!.handlers.onMessage?.({
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'turn-init-uuid',
|
||||
model: 'claude-sonnet-5',
|
||||
apiKeySource: 'none'
|
||||
})
|
||||
|
||||
const { current } = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
expect(current.model).toBe('claude-sonnet-5')
|
||||
expect(current.confirmed).toContain('model')
|
||||
})
|
||||
|
||||
it('starts a new chat on the model Claude will apply, which is what the session persists', async () => {
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
await acquired(startedWithoutATurn(SETTINGS.settingsHaiku), {}, events)
|
||||
|
||||
const reported = events.find((event) => event.type === 'options-reported')
|
||||
expect(reported).toMatchObject({ reportedOptions: { model: 'claude-haiku-4-5-20251001' } })
|
||||
})
|
||||
|
||||
it('shows the applied effort but never persists it as the session options', async () => {
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = await acquired(startedWithoutATurn(SETTINGS.noOverride), {}, events)
|
||||
|
||||
const started = await claudeOpened(adapter, 'session-1')
|
||||
expect(started.reportedOptions).not.toHaveProperty('model')
|
||||
// Saved, it would be restored on every reopen past a later settings.json change.
|
||||
expect(started.reportedOptions).not.toHaveProperty('effort')
|
||||
const reported = events.find((event) => event.type === 'options-reported')
|
||||
expect(
|
||||
reported?.type === 'options-reported' ? reported.reportedOptions : null
|
||||
).not.toHaveProperty('effort')
|
||||
const { current } = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
expect(current.effort).toBe('medium')
|
||||
})
|
||||
|
||||
it('refreshes what Claude applies at turn completion, without work on an options read', async () => {
|
||||
const settings = structuredClone(SETTINGS.settingsHaiku)
|
||||
const claude = startedWithoutATurn(settings)
|
||||
const adapter = await acquired(claude)
|
||||
|
||||
await adapter.setOption({ sessionId: 'session-1', fence: 7, key: 'model', value: 'sonnet' })
|
||||
Object.assign(settings.applied, { model: 'claude-sonnet-5', effort: 'high' })
|
||||
|
||||
const reads = claude.connections[0]!.calls.filter(
|
||||
(call) => call.subtype === 'get_settings'
|
||||
).length
|
||||
const { current } = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
|
||||
expect(current.model).toBe('sonnet')
|
||||
expect(current.effort).toBeUndefined()
|
||||
expect(
|
||||
claude.connections[0]!.calls.filter((call) => call.subtype === 'get_settings')
|
||||
).toHaveLength(reads)
|
||||
claude.connections[0]!.handlers.onMessage?.({
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
uuid: 'finished',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
is_error: false
|
||||
})
|
||||
await vi.waitFor(async () => {
|
||||
expect(
|
||||
(await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current
|
||||
).toMatchObject({ model: 'sonnet', effort: 'high' })
|
||||
})
|
||||
})
|
||||
|
||||
it('drops the applied effort once a restored model replaces the model it described', async () => {
|
||||
const claude = startedWithoutATurn(SETTINGS.noOverride)
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }, events)
|
||||
|
||||
await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9',
|
||||
options: { model: 'haiku' }
|
||||
})
|
||||
|
||||
const started = await claudeOpened(adapter, 'session-1')
|
||||
expect(started.reportedOptions).not.toHaveProperty('model')
|
||||
expect(started.reportedOptions.effort).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalItemBody } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { AgentJournalItemIdentity } from '../../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
import type { StructuredAgentSessionEventSink } from '../../../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink'
|
||||
import { createClaudeJournalTranslator } from '../journal/claude-structured-journal-translation'
|
||||
|
||||
function sinkState() {
|
||||
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body) => items.push({ identity, body }),
|
||||
appendTombstone: () => {},
|
||||
publish: vi.fn()
|
||||
}
|
||||
return { sink, items }
|
||||
}
|
||||
|
||||
function providerFrameKinds(items: { body: AgentJournalItemBody }[]): string[] {
|
||||
return items.flatMap((item) =>
|
||||
item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : []
|
||||
)
|
||||
}
|
||||
|
||||
/** Queue-bookkeeping shape captured from CLI 2.1.258 emission sites. */
|
||||
function commandLifecycle(state: 'started' | 'completed' | 'cancelled', uuid: string) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'command_lifecycle',
|
||||
command_uuid: 'command-1',
|
||||
state,
|
||||
uuid,
|
||||
session_id: 'claude-session'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function userTurn(uuid: string, text: string) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
startsTurn: true as const,
|
||||
message: {
|
||||
type: 'user',
|
||||
uuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
isReplay: true,
|
||||
message: { role: 'user', content: text }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assistantReply(uuid: string, text: string) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'assistant',
|
||||
uuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude command_lifecycle frames', () => {
|
||||
it('keeps queue bookkeeping off the transcript for a whole turn', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userTurn('user-1', 'Reply with exactly PROBE_OK and nothing else.'))
|
||||
translator.handle(commandLifecycle('started', 'lifecycle-1'))
|
||||
translator.handle(assistantReply('assistant-1', 'PROBE_OK'))
|
||||
translator.handle(commandLifecycle('completed', 'lifecycle-2'))
|
||||
translator.handle(commandLifecycle('completed', 'lifecycle-3'))
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
uuid: 'result-1',
|
||||
session_id: 'claude-session',
|
||||
is_error: false,
|
||||
result: 'PROBE_OK',
|
||||
terminal_reason: 'completed'
|
||||
}
|
||||
})
|
||||
|
||||
expect(providerFrameKinds(state.items)).toEqual([])
|
||||
expect(
|
||||
state.items.flatMap((item) =>
|
||||
item.body.kind === 'message' && item.body.role === 'assistant' ? [item.body.blocks] : []
|
||||
)
|
||||
).toEqual([[{ type: 'text', text: 'PROBE_OK' }]])
|
||||
})
|
||||
})
|
||||
@@ -1,143 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import type { AgentJournalItemBody } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { AgentJournalItemIdentity } from '../../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
import type { AgentJournalTurnScope } from '../../../../shared/agent-session/journal/agent-session-journal-turn-scope'
|
||||
import { readAgentJournalTurn } from '../../../../shared/agent-session/lifecycle/records/agent-session-turn-record'
|
||||
import type { StructuredAgentSessionEventSink } from '../../../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink'
|
||||
import { journalTimelineItemsView } from '../../../native-chat/agent-session-journal/writing/journal-timeline-view'
|
||||
import { resolveJournalTimelineItem } from '../../../native-chat/agent-session-journal/writing/journal-timeline-resolution'
|
||||
import {
|
||||
CAPTURED_COMPACT_SESSION_ID,
|
||||
type CapturedCompactEvent
|
||||
} from '../../fixtures/claude-captured-compact-frames.test-fixture'
|
||||
import { createClaudeJournalTranslator } from '../journal/claude-structured-journal-translation'
|
||||
|
||||
const COMMAND_IDENTITY: AgentJournalItemIdentity = {
|
||||
provider: 'orca',
|
||||
clientMessageId: 'command-turn:cmd-1'
|
||||
}
|
||||
const COMMAND_KEY = agentJournalItemKey(COMMAND_IDENTITY)
|
||||
const NOW = 1_800_000_000_000
|
||||
|
||||
const RUNNING = {
|
||||
kind: 'turn' as const,
|
||||
turnId: 'compact:cmd-1',
|
||||
state: 'running' as const,
|
||||
userItemId: 'orca:submission:cmd-1',
|
||||
requestedAt: 900,
|
||||
startedAt: 1_000
|
||||
}
|
||||
|
||||
// Usage limits expire by the system clock; pin it so the quota reset stays an hour ahead.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
type Row = { key: string; body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope }
|
||||
|
||||
function harness() {
|
||||
const rows = new Map<string, Row>([[COMMAND_KEY, { key: COMMAND_KEY, body: RUNNING }]])
|
||||
const writes: Row[] = []
|
||||
const write = (
|
||||
identity: AgentJournalItemIdentity,
|
||||
body: AgentJournalItemBody,
|
||||
turnScope?: AgentJournalTurnScope
|
||||
) => {
|
||||
const row = { key: agentJournalItemKey(identity), body, ...(turnScope ? { turnScope } : {}) }
|
||||
rows.set(row.key, row)
|
||||
writes.push(row)
|
||||
}
|
||||
const view = {
|
||||
...journalTimelineItemsView(
|
||||
() => null,
|
||||
() =>
|
||||
[...rows.values()].map(({ key, body, turnScope }, sequence) => ({
|
||||
itemId: key,
|
||||
body,
|
||||
...(turnScope ? { turnScope } : {}),
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt: 0
|
||||
}))
|
||||
),
|
||||
itemBody: (itemId: string) => rows.get(itemId)?.body ?? null
|
||||
}
|
||||
const revise: NonNullable<StructuredAgentSessionEventSink['tryReviseResolvedItem']> = (
|
||||
bytes,
|
||||
resolution,
|
||||
options
|
||||
) => {
|
||||
const resolved = resolveJournalTimelineItem(view, resolution, options.turnScope, bytes)
|
||||
if (resolved.kind === 'resolved') {
|
||||
write(resolved.item.identity, resolved.item.body, options.turnScope)
|
||||
}
|
||||
return { accepted: true }
|
||||
}
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body, options) => write(identity, body, options.turnScope),
|
||||
appendTombstone: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
tryReviseResolvedItem: revise,
|
||||
tryReviseResolvedItemAndPublish: revise
|
||||
}
|
||||
const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' })
|
||||
const frame = (message: Record<string, unknown>, extra: Record<string, unknown> = {}) =>
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: { session_id: CAPTURED_COMPACT_SESSION_ID, ...message },
|
||||
observedAt: 5_000,
|
||||
...extra
|
||||
})
|
||||
const begin = (sentUuid: string) =>
|
||||
translator.beginCommand({
|
||||
clientMessageId: 'cmd-1',
|
||||
turnId: RUNNING.turnId,
|
||||
identity: COMMAND_IDENTITY,
|
||||
resultIdentity: { provider: 'orca', clientMessageId: 'command-result:cmd-1' },
|
||||
running: RUNNING,
|
||||
providerSessionId: CAPTURED_COMPACT_SESSION_ID,
|
||||
sentUuid
|
||||
})
|
||||
const replay = (events: readonly CapturedCompactEvent[]) => {
|
||||
for (const event of events) {
|
||||
if ('sent' in event) {
|
||||
if (event.sent.text === '/compact') {
|
||||
begin(event.sent.uuid)
|
||||
}
|
||||
} else if ('interrupt' in event) {
|
||||
translator.commandInterruptRequested(RUNNING.turnId)
|
||||
} else {
|
||||
frame(event.frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
const commandTurn = () => readAgentJournalTurn(rows.get(COMMAND_KEY)?.body)
|
||||
const drawn = () =>
|
||||
[...rows.values()].filter((row) => row.key !== COMMAND_KEY && row.body.kind !== 'turn')
|
||||
return { translator, rows, writes, frame, begin, replay, commandTurn, drawn }
|
||||
}
|
||||
|
||||
describe('a captured /compact as the open Claude turn', () => {
|
||||
it('leaves the command running at a result that names another input', () => {
|
||||
const { begin, frame, commandTurn, translator } = harness()
|
||||
begin('compact-input')
|
||||
frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' })
|
||||
frame({ type: 'result', subtype: 'success', is_error: false, user_message_uuid: 'earlier' })
|
||||
expect(commandTurn()?.state).toBe('running')
|
||||
expect(translator.commandTurnId).toBe(RUNNING.turnId)
|
||||
|
||||
frame({
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
user_message_uuid: 'compact-input'
|
||||
})
|
||||
expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'success' })
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
awaitClaudeRequestEnd,
|
||||
CLAUDE_STOP_GRACE_MS,
|
||||
settleClaudeTurnEndWaiters
|
||||
} from './claude-request-end-wait'
|
||||
import type { ClaudeSession } from '../claude-structured-session-state'
|
||||
|
||||
type InFlight = { translator: { currentTurnId: string | null }; dispatchWaiters: unknown[] }
|
||||
|
||||
function claudeWith(
|
||||
currentTurnId: string | null = 'turn-1',
|
||||
unanswered = 0
|
||||
): { claude: ClaudeSession; state: InFlight } {
|
||||
const state: InFlight = {
|
||||
translator: { currentTurnId },
|
||||
dispatchWaiters: Array.from({ length: unanswered }, () => ({}))
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wait reads only `translator.currentTurnId` and `dispatchWaiters`, and keys its waiters by the session's identity.
|
||||
return { claude: state as unknown as ClaudeSession, state }
|
||||
}
|
||||
|
||||
async function settledWithin(promise: Promise<void>, ms: number): Promise<boolean> {
|
||||
let settled = false
|
||||
void promise.then(() => {
|
||||
settled = true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(ms)
|
||||
return settled
|
||||
}
|
||||
|
||||
describe("a Stop's wait for Claude to wind down what it has in flight", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('waits on a send Claude has not echoed, through its turn, to that turn’s end', async () => {
|
||||
const { claude, state } = claudeWith(null, 1)
|
||||
const waiting = awaitClaudeRequestEnd(claude, CLAUDE_STOP_GRACE_MS)
|
||||
state.dispatchWaiters.length = 0
|
||||
state.translator.currentTurnId = 'turn-1'
|
||||
settleClaudeTurnEndWaiters(claude)
|
||||
expect(await settledWithin(waiting, 0)).toBe(false)
|
||||
|
||||
state.translator.currentTurnId = null
|
||||
settleClaudeTurnEndWaiters(claude)
|
||||
expect(await settledWithin(waiting, 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalItemBody } from '../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { AgentJournalItemIdentity } from '../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
import type { StructuredAgentSessionAppendOptions } from '../../native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink'
|
||||
import { ClaudeProvisionalRowCorrections } from './claude-provisional-row-corrections'
|
||||
import type { ClaudeSubagentLinkageVerdict } from './claude-subagent-linkage'
|
||||
|
||||
function identityOf(toolUseId: string): AgentJournalItemIdentity {
|
||||
return { provider: 'orca', clientMessageId: `claude-tool:claude-session:${toolUseId}` }
|
||||
}
|
||||
|
||||
const RUNNING: AgentJournalItemBody = {
|
||||
kind: 'tool-call',
|
||||
callId: 'toolu_2',
|
||||
name: 'Task',
|
||||
input: null,
|
||||
state: 'running'
|
||||
}
|
||||
const COMPLETED: AgentJournalItemBody = { ...RUNNING, state: 'completed' }
|
||||
|
||||
function ledger(initial: Record<string, ClaudeSubagentLinkageVerdict> = {}) {
|
||||
const verdicts = new Map(Object.entries(initial))
|
||||
const rewrites: {
|
||||
identity: AgentJournalItemIdentity
|
||||
body: AgentJournalItemBody
|
||||
options: StructuredAgentSessionAppendOptions
|
||||
}[] = []
|
||||
let published = 0
|
||||
let refuse = false
|
||||
const settledFor = (ref: string): ClaudeSubagentLinkageVerdict => {
|
||||
const verdict = verdicts.get(ref)
|
||||
return verdict && verdict.kind !== 'pending'
|
||||
? verdict
|
||||
: { kind: 'linked', linkage: { agentId: ref, providerParentRef: ref, producerKind: 'agent' } }
|
||||
}
|
||||
const corrections = new ClaudeProvisionalRowCorrections({
|
||||
turnScope: () => AGENT_JOURNAL_THREAD_SCOPE,
|
||||
linkageFor: (ref) => verdicts.get(ref) ?? { kind: 'pending' },
|
||||
settledLinkageFor: (ref) => {
|
||||
const verdict = settledFor(ref)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `settledFor` returns only the linked arm, never `pending`.
|
||||
return verdict as Exclude<ClaudeSubagentLinkageVerdict, { kind: 'pending' }>
|
||||
},
|
||||
rewrite: (identity, body, options) => {
|
||||
if (refuse) {
|
||||
return false
|
||||
}
|
||||
rewrites.push({ identity, body, options })
|
||||
return true
|
||||
},
|
||||
publish: () => {
|
||||
published += 1
|
||||
}
|
||||
})
|
||||
return {
|
||||
corrections,
|
||||
rewrites,
|
||||
announce: (ref: string, agentId: string) =>
|
||||
verdicts.set(ref, {
|
||||
kind: 'linked',
|
||||
linkage: { agentId, providerParentRef: ref, producerKind: 'agent' }
|
||||
}),
|
||||
publishes: () => published,
|
||||
setRefusing: (value: boolean) => {
|
||||
refuse = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ClaudeProvisionalRowCorrections', () => {
|
||||
it('carries the NEWEST body when a row is written provisionally twice', () => {
|
||||
const { corrections, rewrites, announce } = ledger()
|
||||
const stamp = corrections.stampFor('toolu_1')
|
||||
stamp(identityOf('toolu_2'), RUNNING)
|
||||
stamp(identityOf('toolu_2'), COMPLETED)
|
||||
|
||||
announce('toolu_1', 'task-1')
|
||||
corrections.retry()
|
||||
|
||||
expect(rewrites).toHaveLength(1)
|
||||
expect(rewrites[0]?.body).toEqual(COMPLETED)
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveAppImageExtractedRoot } from './appimage-extracted-root'
|
||||
import { resolveAppImageLauncherEndpointPath } from './appimage-stable-launcher'
|
||||
import type * as PreExtractionModule from './appimage-copy-pre-extraction'
|
||||
|
||||
type PreExtraction = typeof PreExtractionModule
|
||||
|
||||
const created: string[] = []
|
||||
let preExtraction: PreExtraction
|
||||
|
||||
beforeEach(async () => {
|
||||
// Why: the module holds this launch's in-flight copy, so every test starts a fresh launch.
|
||||
vi.resetModules()
|
||||
preExtraction = await import('./appimage-copy-pre-extraction')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function makeFixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-appimage-pre-extract-'))
|
||||
created.push(root)
|
||||
const appImagePath = join(root, 'apps', 'Orca.AppImage')
|
||||
await mkdir(join(root, 'apps'), { recursive: true })
|
||||
await writeFile(appImagePath, 'release 1.0.0', { mode: 0o755 })
|
||||
return { root, appImagePath, cacheRootPath: join(root, 'cache') }
|
||||
}
|
||||
|
||||
async function writePayload(cwd: string): Promise<void> {
|
||||
const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin')
|
||||
await mkdir(launcherDir, { recursive: true })
|
||||
await writeFile(join(launcherDir, 'orca-ide'), '', { mode: 0o755 })
|
||||
}
|
||||
|
||||
const acceptCache = async (): Promise<string | null> => null
|
||||
|
||||
describe('preExtractDownloadedAppImageUpdate', () => {
|
||||
it('unpacks the download into the copy its install will run from', async () => {
|
||||
const { root, appImagePath, cacheRootPath } = await makeFixture()
|
||||
const downloadedFile = join(root, 'apps', '.pending-Orca.AppImage')
|
||||
await writeFile(downloadedFile, 'release 2.0.0', { mode: 0o755 })
|
||||
|
||||
const staged = await preExtraction.preExtractDownloadedAppImageUpdate(
|
||||
downloadedFile,
|
||||
async () => true,
|
||||
{
|
||||
appImagePath,
|
||||
cacheRootPath,
|
||||
probeCache: acceptCache,
|
||||
runExtract: async (path, cwd) => {
|
||||
expect(path).toBe(downloadedFile)
|
||||
await writePayload(cwd)
|
||||
},
|
||||
log: () => {}
|
||||
}
|
||||
)
|
||||
expect(staged).toBe(true)
|
||||
|
||||
// What the updater's install does on one filesystem: a rename, which keeps the file identity.
|
||||
await rename(downloadedFile, appImagePath)
|
||||
const installed = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })!
|
||||
expect(existsSync(installed.payloadLauncherPath)).toBe(true)
|
||||
expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type { CodexManagedAccount } from '../../../shared/managed-account-types'
|
||||
import {
|
||||
readStoredCodexCredentialState,
|
||||
waitForManagedCodexAuthReady
|
||||
} from './managed-codex-auth-readiness'
|
||||
|
||||
const roots: string[] = []
|
||||
const testIdToken = 'e30.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.sig'
|
||||
const testChatGptAuth = {
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: {
|
||||
access_token: 'access',
|
||||
id_token: testIdToken,
|
||||
refresh_token: 'refresh',
|
||||
account_id: 'account'
|
||||
},
|
||||
last_refresh: '2026-07-31T00:00:00Z'
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('waitForManagedCodexAuthReady', () => {
|
||||
it('waits for a partial managed ChatGPT credential to become complete', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = createFixture()
|
||||
writeAuth(fixture.home, {
|
||||
tokens: { access_token: 'access', id_token: testIdToken }
|
||||
})
|
||||
expect(readStoredCodexCredentialState(join(fixture.home, 'auth.json'))).toBe('incomplete')
|
||||
let resolved = false
|
||||
const readiness = waitForManagedCodexAuthReady(fixture.args)
|
||||
void readiness?.then(() => {
|
||||
resolved = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(resolved).toBe(false)
|
||||
writeAuth(fixture.home, testChatGptAuth)
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
|
||||
await expect(readiness).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
function createFixture(): {
|
||||
root: string
|
||||
home: string
|
||||
args: Parameters<typeof waitForManagedCodexAuthReady>[0]
|
||||
} {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-managed-codex-auth-'))
|
||||
roots.push(root)
|
||||
const home = join(root, 'account', 'home')
|
||||
mkdirSync(home, { recursive: true })
|
||||
const account = {
|
||||
id: 'account-1',
|
||||
managedHomePath: home,
|
||||
managedHomeRuntime: 'host'
|
||||
} as CodexManagedAccount
|
||||
return {
|
||||
root,
|
||||
home,
|
||||
args: {
|
||||
codexHomePath: home,
|
||||
settings: { codexManagedAccounts: [account] } as GlobalSettings,
|
||||
target: { runtime: 'host' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeAuth(home: string, auth: Record<string, unknown>): void {
|
||||
writeFileSync(join(home, 'auth.json'), JSON.stringify(auth), { mode: 0o600 })
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, toNamespacedPath } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { prepareCodexSessionResume } from './codex-session-resume-preparation'
|
||||
|
||||
const windowsIt = process.platform === 'win32' ? it : it.skip
|
||||
const cleanupPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const cleanupPath of cleanupPaths.splice(0)) {
|
||||
rmSync(cleanupPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Codex session resume on native Windows', () => {
|
||||
windowsIt('resumes an extended-length rollout from its ordinary trusted home', async () => {
|
||||
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-'))
|
||||
cleanupPaths.push(homePath)
|
||||
const rolloutPath = join(
|
||||
homePath,
|
||||
'sessions',
|
||||
'2026',
|
||||
'07',
|
||||
'20',
|
||||
'rollout-019f81b9-19a9-7651-a8d1-352d9420bd11.jsonl'
|
||||
)
|
||||
mkdirSync(dirname(rolloutPath), { recursive: true })
|
||||
writeFileSync(rolloutPath, '{"type":"session_meta"}\n')
|
||||
const transcriptPath = toNamespacedPath(rolloutPath)
|
||||
const resolveVerifiedResumeHome = vi.fn(async () => homePath)
|
||||
|
||||
await expect(
|
||||
prepareCodexSessionResume({
|
||||
sessionId: '019f81b9-19a9-7651-a8d1-352d9420bd11',
|
||||
transcriptPath,
|
||||
trustedCodexHomes: [homePath],
|
||||
getSelectedAccountCodexHome: () => null,
|
||||
systemCodexHomePath: homePath,
|
||||
sharedRuntimeCodexHomePath: null,
|
||||
resolveVerifiedResumeHome
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'resume', codexHomePath: homePath })
|
||||
expect(resolveVerifiedResumeHome).toHaveBeenCalledWith({ homePath, transcriptPath })
|
||||
})
|
||||
})
|
||||
@@ -1,143 +0,0 @@
|
||||
import { assert, describe, expect, it } from 'vitest'
|
||||
import { acquired, fakeCodex } from '../session-lifecycle/codex-structured-session-adapter-fixture'
|
||||
import type { AgentJournalMessageItem } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import {
|
||||
queuedMessageWithEdit,
|
||||
type QueuedMessageEdit
|
||||
} from '../../../../shared/queued-message-edit'
|
||||
|
||||
function skill(name: string, path: string, enabled = true) {
|
||||
return { name, path, description: `${name} skill`, scope: 'user', enabled }
|
||||
}
|
||||
|
||||
const PERSONAL_REVIEW = '/home/me/.agents/skills/review/SKILL.md'
|
||||
const REPO_REVIEW = '/work/repo/.agents/skills/review/SKILL.md'
|
||||
const PET = '/home/me/.codex/plugins/cache/work/pets/skills/create-pet/SKILL.md'
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let turn = 0; turn < 5; turn += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('a running Codex chat lists and runs the skills Codex loaded', () => {
|
||||
it('reports Codex names and files, re-lists on change, and sends a pick by its file', async () => {
|
||||
let listed = [
|
||||
skill('review', PERSONAL_REVIEW),
|
||||
skill('review', REPO_REVIEW),
|
||||
skill('work-pets:create-pet', PET),
|
||||
skill('old', '/home/me/.agents/skills/old/SKILL.md', false)
|
||||
]
|
||||
const codex = fakeCodex({
|
||||
'skills/list': () => ({ data: [{ cwd: '/work/repo', skills: listed, errors: [] }] }),
|
||||
'turn/start': () => ({ turn: { id: 'turn-1' } })
|
||||
})
|
||||
const adapter = await acquired(codex)
|
||||
await settle()
|
||||
|
||||
const reported = adapter.readCommands('session-1') ?? []
|
||||
expect(reported.filter((row) => row.kind === 'command').map((row) => row.name)).toEqual(
|
||||
expect.arrayContaining(['model', 'clear', 'compact', 'goal'])
|
||||
)
|
||||
expect(reported.filter((row) => row.kind === 'skill')).toEqual(
|
||||
listed
|
||||
.filter(({ enabled }) => enabled)
|
||||
.map(({ name, path, description }) => ({
|
||||
name,
|
||||
path,
|
||||
description,
|
||||
kind: 'skill'
|
||||
}))
|
||||
)
|
||||
|
||||
listed = [...listed, skill('fresh', '/home/me/.agents/skills/fresh/SKILL.md')]
|
||||
codex.connections[0].handlers.onNotification?.('skills/changed', {})
|
||||
await settle()
|
||||
expect(
|
||||
codex.connections[0].calls.findLast((call) => call.method === 'skills/list')
|
||||
).toMatchObject({ params: { cwds: ['/work/repo'], forceReload: true } })
|
||||
expect(adapter.readCommands('session-1')?.some((row) => row.name === 'fresh')).toBe(true)
|
||||
|
||||
await adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'pick-1',
|
||||
fence: 7,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: '$review this, then $gone' }],
|
||||
skills: [
|
||||
{ name: 'review', path: REPO_REVIEW },
|
||||
// Removed from the text after selection.
|
||||
{ name: 'work-pets:create-pet', path: PET },
|
||||
// Unloaded paths remain plain $name text.
|
||||
{ name: 'gone', path: '/elsewhere/gone/SKILL.md' }
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(
|
||||
codex.connections[0].calls.findLast((call) => call.method === 'turn/start')?.params?.input
|
||||
).toEqual([
|
||||
{ type: 'text', text: '$review this, then $gone' },
|
||||
{ type: 'skill', name: 'review', path: REPO_REVIEW }
|
||||
])
|
||||
|
||||
const text = 'project: $review personal: $review'
|
||||
const skills = [
|
||||
{ name: 'review', path: REPO_REVIEW },
|
||||
{ name: 'review', path: PERSONAL_REVIEW }
|
||||
]
|
||||
const body: AgentJournalMessageItem = {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [
|
||||
{ type: 'text', text },
|
||||
{ type: 'image-ref', path: '/work/repo/example.png' }
|
||||
],
|
||||
sentAs: 'goal',
|
||||
from: { kind: 'agent', senders: [], orchestration: null },
|
||||
skills
|
||||
}
|
||||
const { blocks: _blocks, skills: _picks, ...retainedFields } = body
|
||||
const edit = (body: AgentJournalMessageItem, input: QueuedMessageEdit) => {
|
||||
const result = queuedMessageWithEdit(body, input)
|
||||
assert(result.ok)
|
||||
return result.body
|
||||
}
|
||||
const unchanged = edit(body, { text })
|
||||
expect(unchanged).toBe(body)
|
||||
expect(unchanged.skills).toBe(skills)
|
||||
const deleted = edit(body, { text: 'later' })
|
||||
const retyped = edit(deleted, { text })
|
||||
for (const [edited, expectedSkills] of [
|
||||
[unchanged, skills],
|
||||
[edit(body, { text: 'project: $review' }), []],
|
||||
[edit(body, { text: `${text} please` }), []],
|
||||
[
|
||||
edit(body, {
|
||||
text,
|
||||
images: [{ type: 'image-ref', path: '/work/repo/example.png', alt: 'edited' }]
|
||||
}),
|
||||
[]
|
||||
],
|
||||
[retyped, []],
|
||||
[edit(body, { text: '/review', skills: [{ name: 'review', path: REPO_REVIEW }] }), []]
|
||||
] as const) {
|
||||
expect(edited).toMatchObject(retainedFields)
|
||||
const adapter = await acquired(codex)
|
||||
await settle()
|
||||
const connection = codex.connections.at(-1)!
|
||||
await adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'queued-pick',
|
||||
fence: 7,
|
||||
body: edited
|
||||
})
|
||||
expect(connection.calls.find((call) => call.method === 'turn/start')?.params?.input).toEqual([
|
||||
...edited.blocks.filter((block) => block.type === 'text'),
|
||||
{ type: 'localImage', path: '/work/repo/example.png' },
|
||||
...expectedSkills.map((s) => ({ type: 'skill', ...s }))
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { chat, SESSION, THREAD, turn } from '../threads/codex-thread-catch-up-contract-test-fixture'
|
||||
import { codexNonMessageIdentity } from './codex-thread-item-identity'
|
||||
import { codexTurnLifecycleIdentity } from '../journal/codex-structured-journal-translation-turns'
|
||||
import { agentJournalItemKey } from '../../../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import { nativeChatGeneratedImageAnswers } from '../../../../shared/native-chat/transcript/images/native-chat-generated-image-answers'
|
||||
import { readTestJournalItems } from '../../../native-chat/agent-session-journal/journal-observation.test-support'
|
||||
|
||||
it.each(['complete', 'sparse'])(
|
||||
'enriches %s authoritative saved generation once and preserves the result through replay',
|
||||
async (shape) => {
|
||||
const c = await chat({ now: 1000 })
|
||||
const identity = codexNonMessageIdentity(THREAD, 'turn-1', 'image-1')
|
||||
const key = agentJournalItemKey(identity)
|
||||
const turnIdentity = codexTurnLifecycleIdentity(SESSION, 'turn-1')
|
||||
await c.journal.appendItem(
|
||||
turnIdentity,
|
||||
{ kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'success' },
|
||||
{ fence: 1, turnScope: { kind: 'thread' } }
|
||||
)
|
||||
await c.journal.appendItem(
|
||||
identity,
|
||||
{
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'Generated image' },
|
||||
{ type: 'image-ref', path: '/tmp/elephant.png' }
|
||||
]
|
||||
},
|
||||
{ fence: 1, turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turnIdentity) } }
|
||||
)
|
||||
const supplied = {
|
||||
...turn(1),
|
||||
items: [
|
||||
{
|
||||
type: 'imageGeneration',
|
||||
id: 'image-1',
|
||||
...(shape === 'complete' ? { status: 'completed', savedPath: '/tmp/elephant.png' } : {})
|
||||
},
|
||||
{ type: 'agentMessage', id: 'answer', text: 'Here is your elephant.' }
|
||||
]
|
||||
}
|
||||
await c.open([], { returnedTurns: [supplied], paginate: false })
|
||||
const saved = (await c.journal.reads.item(key))!
|
||||
expect(saved.body).toEqual({
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'Generated image', presentation: 'generated-image' },
|
||||
{ type: 'image-ref', path: '/tmp/elephant.png' }
|
||||
]
|
||||
})
|
||||
const messages = projectStructuredItemsToNativeChat(await readTestJournalItems(c.journal))
|
||||
const projected = nativeChatGeneratedImageAnswers(
|
||||
messages,
|
||||
messages.map(() => 'turn-1'),
|
||||
new Set(['turn-1']),
|
||||
() => []
|
||||
)
|
||||
expect(
|
||||
projected.find((row) =>
|
||||
row.blocks.some((block) => block.type === 'text' && block.text === 'Here is your elephant.')
|
||||
)?.blocks
|
||||
).toEqual([
|
||||
{ type: 'text', text: 'Here is your elephant.' },
|
||||
{ type: 'image-ref', path: '/tmp/elephant.png' }
|
||||
])
|
||||
await c.open([], { returnedTurns: [supplied], paginate: false })
|
||||
expect(await c.journal.reads.item(key)).toEqual(saved)
|
||||
}
|
||||
)
|
||||
@@ -1,185 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
|
||||
import {
|
||||
LIFECYCLE_KEY,
|
||||
THREAD_ID,
|
||||
TURN_ID,
|
||||
notification,
|
||||
recorder,
|
||||
reduced,
|
||||
translatorFor
|
||||
} from './codex-turn-lifecycle-contract-test-fixture'
|
||||
|
||||
const DIAGNOSTIC = 'Provider diagnostic: internal request failure'
|
||||
|
||||
function error(code: unknown, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
threadId: THREAD_ID,
|
||||
turnId: TURN_ID,
|
||||
willRetry: false,
|
||||
error: { message: DIAGNOSTIC, codexErrorInfo: code },
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
function statuses(tap: ReturnType<typeof recorder>) {
|
||||
return reduced(tap.rows).flatMap((row) =>
|
||||
row.body.kind === 'status' && row.body.failure?.kind !== 'providerRetrying' ? [row.body] : []
|
||||
)
|
||||
}
|
||||
|
||||
describe('Codex terminal failure categories', () => {
|
||||
it('ends a recorded-shape failed completion carrying an unknown future code', () => {
|
||||
const frames: {
|
||||
case: string
|
||||
method: string
|
||||
params: {
|
||||
threadId: string
|
||||
turn?: { id: string; status: string; error?: Record<string, unknown> }
|
||||
}
|
||||
}[] = readFileSync(
|
||||
join(__dirname, '../../__fixtures__/codex-app-server-turn-endings.jsonl'),
|
||||
'utf8'
|
||||
)
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line))
|
||||
const captured = frames.find(
|
||||
(frame) => frame.case === '0.141.0-conn-refused' && frame.method === 'turn/completed'
|
||||
)
|
||||
if (!captured?.params.turn) {
|
||||
throw new Error('Missing captured failed completion')
|
||||
}
|
||||
const tap = recorder()
|
||||
const translator = createCodexJournalTranslator({
|
||||
sink: tap.sink,
|
||||
sessionId: 'session-1',
|
||||
primaryThreadId: () => captured.params.threadId
|
||||
})
|
||||
const on = (method: string, params: unknown) =>
|
||||
translator.handle({
|
||||
type: 'notification',
|
||||
sessionId: 'session-1',
|
||||
threadId: captured.params.threadId,
|
||||
method,
|
||||
params
|
||||
})
|
||||
on('turn/started', { turn: { id: captured.params.turn.id } })
|
||||
expect(reduced(tap.rows).map((row) => row.body)).toMatchObject([
|
||||
{ kind: 'turn', state: 'running' }
|
||||
])
|
||||
expect(
|
||||
on('turn/completed', {
|
||||
...captured.params,
|
||||
turn: {
|
||||
...captured.params.turn,
|
||||
error: { ...captured.params.turn.error, codexErrorInfo: 'futureFailureAfter0167' }
|
||||
}
|
||||
})
|
||||
).toEqual({ accepted: true })
|
||||
expect(reduced(tap.rows).map((row) => row.body)).toMatchObject([
|
||||
{ kind: 'turn', state: 'completed', outcome: 'failure' },
|
||||
{ kind: 'status', failure: { kind: 'providerResponseFailed' } }
|
||||
])
|
||||
})
|
||||
|
||||
it('shows a completion-only safety stop while preserving an ordinary Stop', () => {
|
||||
for (const error of [{ message: DIAGNOSTIC, codexErrorInfo: 'tooManyDenials' }, null]) {
|
||||
const tap = recorder()
|
||||
const translator = translatorFor(tap)
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
|
||||
translator.handle(
|
||||
notification('turn/completed', { turn: { id: TURN_ID, status: 'interrupted', error } })
|
||||
)
|
||||
expect(reduced(tap.rows).find((row) => row.key === LIFECYCLE_KEY)?.body).toMatchObject({
|
||||
kind: 'turn',
|
||||
state: 'interrupted',
|
||||
outcome: 'cancellation'
|
||||
})
|
||||
expect(statuses(tap)).toMatchObject(
|
||||
error ? [{ failure: { kind: 'providerSafetyStopped' } }] : []
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps completion-only sign-in diagnostics behind Details', () => {
|
||||
const tap = recorder()
|
||||
const translator = translatorFor(tap)
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
|
||||
translator.handle(
|
||||
notification('turn/completed', {
|
||||
turn: {
|
||||
id: TURN_ID,
|
||||
status: 'failed',
|
||||
error: error({ responseStreamDisconnected: { httpStatusCode: 401 } }).error
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(statuses(tap)).toMatchObject([{ failure: { kind: 'notSignedIn' } }])
|
||||
expect(statuses(tap)[0]?.text).not.toContain(DIAGNOSTIC)
|
||||
expect(statuses(tap)[0]?.providerFrame?.payload.head).toContain(DIAGNOSTIC)
|
||||
})
|
||||
|
||||
it('keeps recovered sign-in advice through completion backpressure without another failure row', () => {
|
||||
const tap = recorder()
|
||||
let rejectCompletion = true
|
||||
tap.sink.tryAppendItem = (identity, body, options) => {
|
||||
if (body.kind === 'turn' && body.state === 'completed' && rejectCompletion) {
|
||||
rejectCompletion = false
|
||||
return { accepted: false, reason: 'backpressure' }
|
||||
}
|
||||
tap.sink.appendItem(identity, body, options)
|
||||
return { accepted: true }
|
||||
}
|
||||
const translator = translatorFor(tap)
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
|
||||
translator.handle(
|
||||
notification('error', {
|
||||
...error({ responseStreamDisconnected: { httpStatusCode: 401 } }, { willRetry: true }),
|
||||
error: {
|
||||
message: 'Reconnecting... 1/2',
|
||||
codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 401 } },
|
||||
additionalDetails: DIAGNOSTIC
|
||||
}
|
||||
})
|
||||
)
|
||||
translator.handle(notification('error', error('other')))
|
||||
const completion = notification('turn/completed', {
|
||||
turn: { id: TURN_ID, status: 'failed', error: error('other').error }
|
||||
})
|
||||
expect(translator.handle(completion)).toEqual({ accepted: false, reason: 'backpressure' })
|
||||
expect(translator.handle(completion)).toEqual({ accepted: true })
|
||||
const terminal = tap.rows.filter(
|
||||
(row) => row.body.kind === 'status' && row.body.failure?.kind !== 'providerRetrying'
|
||||
)
|
||||
expect(terminal.map((row) => row.body)).toMatchObject([{ failure: { kind: 'notSignedIn' } }])
|
||||
expect(statuses(tap)[0]?.text).not.toContain(DIAGNOSTIC)
|
||||
expect(statuses(tap)[0]?.providerFrame?.payload.head).toContain(DIAGNOSTIC)
|
||||
expect(reduced(tap.rows).find((row) => row.key === LIFECYCLE_KEY)?.body).toMatchObject({
|
||||
state: 'completed',
|
||||
outcome: 'failure'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the last non-other retry only for matching text and scope, including empty details', () => {
|
||||
const tap = recorder()
|
||||
const translator = translatorFor(tap)
|
||||
const retry = (code: unknown, details: string) =>
|
||||
notification('error', {
|
||||
...error(code, { willRetry: true }),
|
||||
error: { message: DIAGNOSTIC, codexErrorInfo: code, additionalDetails: details }
|
||||
})
|
||||
translator.handle(retry({ responseStreamDisconnected: { httpStatusCode: 401 } }, ''))
|
||||
translator.handle(notification('error', error('other')))
|
||||
expect(statuses(tap).at(-1)?.failure?.kind).toBe('providerResponseFailed')
|
||||
translator.handle(retry({ responseStreamDisconnected: { httpStatusCode: 401 } }, DIAGNOSTIC))
|
||||
translator.handle(retry('futureRetryCode', DIAGNOSTIC))
|
||||
translator.handle(notification('error', error('other')))
|
||||
expect(statuses(tap).at(-1)?.failure?.kind).toBe('providerResponseFailed')
|
||||
translator.handle(retry({ responseStreamDisconnected: { httpStatusCode: 401 } }, DIAGNOSTIC))
|
||||
translator.handle(notification('error', error('other', { turnId: 'another-turn' })))
|
||||
expect(statuses(tap).at(-1)?.failure?.kind).toBe('providerResponseFailed')
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseMinidumpCrashSignature } from './minidump-crash-signature'
|
||||
|
||||
const STREAM_TYPE_MODULE_LIST = 4
|
||||
const STREAM_TYPE_MEMORY_LIST = 5
|
||||
const STREAM_TYPE_SYSTEM_INFO = 7
|
||||
const VER_PLATFORM_WIN32_NT = 2
|
||||
|
||||
const STREAM_TYPE_EXCEPTION = 6
|
||||
const CONTEXT_SIZE = 0x4d0
|
||||
// Electron 43.7.8 x64 bytes at electron.exe+0x23d3f88 for comparison with dump capture.
|
||||
|
||||
type FaultDump = {
|
||||
code: number
|
||||
rip: bigint
|
||||
params?: bigint[]
|
||||
rsp?: bigint
|
||||
contextFlags?: number
|
||||
memory?: { start: bigint; bytes: Buffer }[]
|
||||
modules?: { base: bigint; size: number; name: string }[]
|
||||
platformId?: number
|
||||
}
|
||||
|
||||
function buildFaultDump(options: FaultDump): Buffer {
|
||||
const streamCount =
|
||||
1 +
|
||||
(options.memory ? 1 : 0) +
|
||||
(options.modules ? 1 : 0) +
|
||||
(options.platformId === undefined ? 0 : 1)
|
||||
let cursor = 32 + streamCount * 12
|
||||
const regions: Buffer[] = []
|
||||
const append = (buf: Buffer): number => {
|
||||
const rva = cursor
|
||||
regions.push(buf)
|
||||
cursor += buf.length
|
||||
return rva
|
||||
}
|
||||
|
||||
const context = Buffer.alloc(CONTEXT_SIZE)
|
||||
context.writeUInt32LE(options.contextFlags ?? 0x0010001f, 0x30)
|
||||
context.writeBigUInt64LE(options.rsp ?? 0n, 0x98)
|
||||
const contextRva = append(context)
|
||||
|
||||
const exception = Buffer.alloc(168)
|
||||
exception.writeUInt32LE(7, 0)
|
||||
exception.writeUInt32LE(options.code, 8)
|
||||
exception.writeBigUInt64LE(options.rip, 24)
|
||||
const params = options.params ?? []
|
||||
exception.writeUInt32LE(params.length, 32)
|
||||
params.forEach((value, index) => exception.writeBigUInt64LE(value, 40 + index * 8))
|
||||
exception.writeUInt32LE(CONTEXT_SIZE, 160)
|
||||
exception.writeUInt32LE(contextRva, 164)
|
||||
const streams = [{ type: STREAM_TYPE_EXCEPTION, size: 168, rva: append(exception) }]
|
||||
|
||||
if (options.memory) {
|
||||
const dataRvas = options.memory.map((range) => append(range.bytes))
|
||||
const list = Buffer.alloc(4 + options.memory.length * 16)
|
||||
list.writeUInt32LE(options.memory.length, 0)
|
||||
options.memory.forEach((range, index) => {
|
||||
list.writeBigUInt64LE(range.start, 4 + index * 16)
|
||||
list.writeUInt32LE(range.bytes.length, 12 + index * 16)
|
||||
list.writeUInt32LE(dataRvas[index], 16 + index * 16)
|
||||
})
|
||||
streams.push({ type: STREAM_TYPE_MEMORY_LIST, size: list.length, rva: append(list) })
|
||||
}
|
||||
|
||||
if (options.modules) {
|
||||
const nameRvas = options.modules.map((module) => {
|
||||
const name = Buffer.from(module.name, 'utf16le')
|
||||
const length = Buffer.alloc(4)
|
||||
length.writeUInt32LE(name.length, 0)
|
||||
return append(Buffer.concat([length, name]))
|
||||
})
|
||||
const list = Buffer.alloc(4 + options.modules.length * 108)
|
||||
list.writeUInt32LE(options.modules.length, 0)
|
||||
options.modules.forEach((module, index) => {
|
||||
list.writeBigUInt64LE(module.base, 4 + index * 108)
|
||||
list.writeUInt32LE(module.size, 12 + index * 108)
|
||||
list.writeUInt32LE(nameRvas[index], 24 + index * 108)
|
||||
})
|
||||
streams.push({ type: STREAM_TYPE_MODULE_LIST, size: list.length, rva: append(list) })
|
||||
}
|
||||
|
||||
if (options.platformId !== undefined) {
|
||||
const systemInfo = Buffer.alloc(56)
|
||||
systemInfo.writeUInt32LE(options.platformId, 20)
|
||||
streams.push({ type: STREAM_TYPE_SYSTEM_INFO, size: 56, rva: append(systemInfo) })
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(32 + streamCount * 12)
|
||||
header.writeUInt32LE(0x504d444d, 0)
|
||||
header.writeUInt32LE(streamCount, 8)
|
||||
header.writeUInt32LE(32, 12)
|
||||
streams.forEach((stream, index) => {
|
||||
header.writeUInt32LE(stream.type, 32 + index * 12)
|
||||
header.writeUInt32LE(stream.size, 36 + index * 12)
|
||||
header.writeUInt32LE(stream.rva, 40 + index * 12)
|
||||
})
|
||||
return Buffer.concat([header, ...regions])
|
||||
}
|
||||
|
||||
describe('minidump fault context', () => {
|
||||
it('keeps only code bytes for non-AV exceptions and truncates at the captured range', async () => {
|
||||
const signature = await parseMinidumpCrashSignature(
|
||||
buildFaultDump({
|
||||
code: 0x80000003,
|
||||
rip: 0x5000n,
|
||||
params: [1n, 0x20n],
|
||||
memory: [
|
||||
{ start: 0x100n, bytes: Buffer.from('aa', 'hex') },
|
||||
{ start: 0x4ffen, bytes: Buffer.from('9090cc0f0b', 'hex') }
|
||||
],
|
||||
platformId: VER_PLATFORM_WIN32_NT,
|
||||
modules: [{ base: 0x4000n, size: 0x2000, name: 'Orca.exe' }]
|
||||
})
|
||||
)
|
||||
expect(signature?.fault).toEqual({ codeBytes: 'cc0f0b' })
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AvccFrameParser, type AvccFrame } from './avcc-frame-parser'
|
||||
|
||||
function packet(tag: number, payload: number[]): number[] {
|
||||
const length = payload.length + 1
|
||||
return [
|
||||
length >>> 24,
|
||||
(length >>> 16) & 0xff,
|
||||
(length >>> 8) & 0xff,
|
||||
length & 0xff,
|
||||
tag,
|
||||
...payload
|
||||
]
|
||||
}
|
||||
|
||||
function parse(chunks: number[][]): { frame: AvccFrame; bytes: number[] }[] {
|
||||
const frames: { frame: AvccFrame; bytes: number[] }[] = []
|
||||
const parser = new AvccFrameParser((frame) =>
|
||||
frames.push({ frame, bytes: [...new Uint8Array(frame.bytes)] })
|
||||
)
|
||||
for (const chunk of chunks) {
|
||||
parser.push(Uint8Array.from(chunk))
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
describe('AvccFrameParser', () => {
|
||||
it('reassembles description, IDR and delta packets split anywhere across chunks', () => {
|
||||
const wire = [...packet(1, [1, 0x64, 0, 0x33]), ...packet(2, [9, 9, 9]), ...packet(3, [7])]
|
||||
const frames = parse([wire.slice(0, 3), wire.slice(3, 11), wire.slice(11, 12), wire.slice(12)])
|
||||
|
||||
expect(frames.map(({ frame, bytes }) => [frame.config, frame.keyFrame, bytes])).toEqual([
|
||||
[true, false, [1, 0x64, 0, 0x33]],
|
||||
[false, true, [9, 9, 9]],
|
||||
[false, false, [7]]
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,202 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
runEnvironmentDoctor,
|
||||
type EnvironmentDoctorCommandResult,
|
||||
type EnvironmentDoctorDeps
|
||||
} from './environment-doctor'
|
||||
|
||||
const ROOT = '/work/repo'
|
||||
const ok = (stdout = ''): EnvironmentDoctorCommandResult => ({
|
||||
code: 0,
|
||||
stdout,
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
const failed = (stderr: string, code = 128): EnvironmentDoctorCommandResult => ({
|
||||
code,
|
||||
stdout: '',
|
||||
stderr,
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
function makeDeps(options: {
|
||||
files?: Record<string, string>
|
||||
installed?: string[]
|
||||
agents?: string[]
|
||||
git?: (args: readonly string[]) => EnvironmentDoctorCommandResult
|
||||
agentStatus?: Record<string, EnvironmentDoctorCommandResult>
|
||||
freeBytes?: number
|
||||
setup?: EnvironmentDoctorCommandResult
|
||||
}): EnvironmentDoctorDeps {
|
||||
const files = options.files ?? {}
|
||||
return {
|
||||
platform: 'linux',
|
||||
prepare: async () => {},
|
||||
run: async (program, args) => {
|
||||
if (program === 'git') {
|
||||
return options.git?.(args) ?? failed('not a git repository')
|
||||
}
|
||||
return options.agentStatus?.[program] ?? ok()
|
||||
},
|
||||
findCommands: async (commands) =>
|
||||
new Set(commands.filter((command) => (options.installed ?? ['git']).includes(command))),
|
||||
detectAgents: async () => options.agents ?? [],
|
||||
readFile: async (path) => files[path.replace(`${ROOT}/`, '')] ?? null,
|
||||
exists: async (path) => path.replace(`${ROOT}/`, '') in files,
|
||||
isDirectory: async (path) => path === ROOT || path.startsWith(`${ROOT}/`),
|
||||
freeBytes: async () => options.freeBytes ?? 50 * 1024 ** 3,
|
||||
runSetupScript: vi.fn(async () => options.setup ?? ok())
|
||||
}
|
||||
}
|
||||
|
||||
function gitRepo(overrides: Record<string, EnvironmentDoctorCommandResult> = {}) {
|
||||
return (args: readonly string[]): EnvironmentDoctorCommandResult => {
|
||||
const key = args.join(' ')
|
||||
for (const [prefix, result] of Object.entries(overrides)) {
|
||||
if (key.startsWith(prefix)) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
if (key.startsWith('rev-parse --is-inside-work-tree')) {
|
||||
return ok(`true\n${ROOT}\n/work/main/.git\n`)
|
||||
}
|
||||
if (key === 'symbolic-ref --quiet --short HEAD') {
|
||||
return ok('feature\n')
|
||||
}
|
||||
if (key === 'remote') {
|
||||
return ok('origin\n')
|
||||
}
|
||||
return ok()
|
||||
}
|
||||
}
|
||||
|
||||
function statuses(result: Awaited<ReturnType<typeof runEnvironmentDoctor>>) {
|
||||
return Object.fromEntries(result.checks.map((check) => [check.id, check.status]))
|
||||
}
|
||||
|
||||
describe('runEnvironmentDoctor', () => {
|
||||
it('fails on a missing repo-implied CLI but only warns on a signed-out agent', async () => {
|
||||
const result = await runEnvironmentDoctor(
|
||||
{ worktreePath: ROOT },
|
||||
makeDeps({
|
||||
files: { 'package.json': '{"packageManager":"pnpm@10.0.0"}' },
|
||||
installed: ['git', 'node', 'claude', 'codex'],
|
||||
agents: ['claude', 'codex', 'gemini'],
|
||||
agentStatus: { claude: failed('', 1) },
|
||||
git: gitRepo()
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(statuses(result)).toMatchObject({
|
||||
'tool.git': 'pass',
|
||||
'tool.node': 'pass',
|
||||
'tool.pnpm': 'fail',
|
||||
'agent.claude': 'warn',
|
||||
'agent.codex': 'pass',
|
||||
'agent.cli': 'pass',
|
||||
'git.fetch': 'pass',
|
||||
'git.push': 'pass',
|
||||
'setup.script': 'warn',
|
||||
'disk.free': 'pass'
|
||||
})
|
||||
expect(result.checks.find((check) => check.id === 'agent.claude')?.remediation).toContain(
|
||||
'claude auth login'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unreachable remote without leaking the credential in its URL', async () => {
|
||||
const result = await runEnvironmentDoctor(
|
||||
{ worktreePath: ROOT },
|
||||
makeDeps({
|
||||
git: gitRepo({
|
||||
'ls-remote': failed(
|
||||
"fatal: unable to access 'https://x-access-token:ghp_secret123@github.com/o/r.git/': 403"
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(statuses(result)['git.fetch']).toBe('fail')
|
||||
expect(statuses(result)['git.push']).toBeUndefined()
|
||||
expect(JSON.stringify(result)).not.toContain('ghp_secret123')
|
||||
})
|
||||
|
||||
it('probes push with a dry run that skips hooks and targets a probe ref', async () => {
|
||||
const calls: string[] = []
|
||||
const deps = makeDeps({
|
||||
git: (args) => {
|
||||
calls.push(args.join(' '))
|
||||
return gitRepo({ push: failed('remote: Permission denied', 1) })(args)
|
||||
}
|
||||
})
|
||||
const result = await runEnvironmentDoctor({ worktreePath: ROOT }, deps)
|
||||
|
||||
expect(statuses(result)['git.push']).toBe('fail')
|
||||
expect(calls).toContain(
|
||||
'push --dry-run --no-verify --porcelain origin HEAD:refs/heads/orca-env-doctor-push-probe'
|
||||
)
|
||||
})
|
||||
|
||||
it('runs the setup script only when asked and gates on its exit code', async () => {
|
||||
const files = { 'orca.yaml': 'scripts:\n setup: pnpm install\n' }
|
||||
const passive = makeDeps({ files, git: gitRepo() })
|
||||
const passiveResult = await runEnvironmentDoctor({ worktreePath: ROOT }, passive)
|
||||
expect(passive.runSetupScript).not.toHaveBeenCalled()
|
||||
expect(statuses(passiveResult)['setup.run']).toBe('warn')
|
||||
|
||||
const active = makeDeps({ files, git: gitRepo(), setup: failed('ERR_PNPM_NO_LOCKFILE', 1) })
|
||||
const activeResult = await runEnvironmentDoctor({ worktreePath: ROOT, runSetup: true }, active)
|
||||
expect(active.runSetupScript).toHaveBeenCalledWith({
|
||||
worktreePath: ROOT,
|
||||
rootPath: '/work/main',
|
||||
script: 'pnpm install'
|
||||
})
|
||||
expect(activeResult.ok).toBe(false)
|
||||
expect(activeResult.checks.find((check) => check.id === 'setup.run')).toMatchObject({
|
||||
status: 'fail',
|
||||
message: 'Setup script exited with code 1.'
|
||||
})
|
||||
expect(activeResult.checks.find((check) => check.id === 'setup.run')?.remediation).toContain(
|
||||
'ERR_PNPM_NO_LOCKFILE'
|
||||
)
|
||||
})
|
||||
|
||||
it('reads the repo root when started in a subfolder, and fails on an unreadable checkout', async () => {
|
||||
const files = { 'package.json': '{"packageManager":"pnpm@10.0.0"}' }
|
||||
const fromSubfolder = await runEnvironmentDoctor(
|
||||
{ worktreePath: `${ROOT}/src` },
|
||||
makeDeps({ files, installed: ['git', 'node', 'pnpm'], git: gitRepo() })
|
||||
)
|
||||
expect(fromSubfolder.worktreePath).toBe(ROOT)
|
||||
expect(statuses(fromSubfolder)['tool.pnpm']).toBe('pass')
|
||||
|
||||
const dubious = await runEnvironmentDoctor(
|
||||
{ worktreePath: ROOT },
|
||||
makeDeps({
|
||||
git: gitRepo({
|
||||
'rev-parse --is-inside-work-tree': failed(
|
||||
"fatal: detected dubious ownership in repository at '/work/repo'"
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(statuses(dubious)['git.repo']).toBe('fail')
|
||||
expect(dubious.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('skips Git checks for a folder workspace and fails on a nearly full disk', async () => {
|
||||
const result = await runEnvironmentDoctor(
|
||||
{ worktreePath: ROOT },
|
||||
makeDeps({ freeBytes: 512 * 1024 ** 2 })
|
||||
)
|
||||
|
||||
expect(statuses(result)).toMatchObject({
|
||||
'git.repo': 'pass',
|
||||
'agent.cli': 'warn',
|
||||
'disk.free': 'fail'
|
||||
})
|
||||
expect(statuses(result)['git.fetch']).toBeUndefined()
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { applicationInfo, openPath, runProcess } = vi.hoisted(() => ({
|
||||
applicationInfo: vi.fn(),
|
||||
openPath: vi.fn(),
|
||||
runProcess: vi.fn()
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
app: { getApplicationInfoForProtocol: applicationInfo },
|
||||
shell: { openPath }
|
||||
}))
|
||||
vi.mock('@orca/process-host', () => ({ runProcess }))
|
||||
|
||||
import { openPathInDefaultApp } from './default-app-location-open'
|
||||
import type { RuntimeFileOpenPosition } from '../../shared/file-access/runtime-editor-file-open'
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
const filePath = '/tmp/project with spaces/main #1.json'
|
||||
|
||||
function setPlatform(value: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value })
|
||||
}
|
||||
|
||||
describe('openPathInDefaultApp', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
setPlatform('darwin')
|
||||
applicationInfo.mockResolvedValue({ path: '/Applications/Cursor.app' })
|
||||
runProcess.mockResolvedValue({ code: 0, timedOut: false })
|
||||
openPath.mockResolvedValue('')
|
||||
})
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Cursor.app', { line: 551, column: 7 }, '551:7'],
|
||||
['Visual Studio Code.app', { line: 551 }, '551'],
|
||||
['Visual Studio Code - Insiders.app', { line: 551, column: 7 }, '551:7']
|
||||
])(
|
||||
'hands the line to a default %s instead of the OS opener',
|
||||
async (bundle, location, suffix) => {
|
||||
const appPath = `/Users/ada/Applications/${bundle}`
|
||||
applicationInfo.mockResolvedValue({ path: appPath })
|
||||
|
||||
await expect(openPathInDefaultApp(filePath, location)).resolves.toBe('')
|
||||
|
||||
expect(applicationInfo).toHaveBeenCalledWith(
|
||||
'file:///tmp/project%20with%20spaces/main%20%231.json'
|
||||
)
|
||||
expect(runProcess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
program: '/usr/bin/open',
|
||||
args: ['-n', '-g', '-a', appPath, '--args', '--goto', `${filePath}:${suffix}`],
|
||||
env: {},
|
||||
timeoutMs: null
|
||||
})
|
||||
)
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each<[string, () => void]>([
|
||||
['another default app', () => applicationInfo.mockResolvedValue({ path: '/A/Zed.app' })],
|
||||
['a failed association lookup', () => applicationInfo.mockRejectedValue(new Error('none'))],
|
||||
['an open that exits non-zero', () => runProcess.mockResolvedValue({ code: 1 })],
|
||||
['an open that cannot spawn', () => runProcess.mockRejectedValue(new Error('ENOENT'))]
|
||||
])('opens once with the OS opener on %s', async (_name, arrange) => {
|
||||
arrange()
|
||||
openPath.mockResolvedValue('no app')
|
||||
|
||||
await expect(openPathInDefaultApp(filePath, { line: 551 })).resolves.toBe('no app')
|
||||
expect(openPath).toHaveBeenCalledTimes(1)
|
||||
expect(openPath).toHaveBeenCalledWith(filePath)
|
||||
})
|
||||
|
||||
it.each<[string, NodeJS.Platform, RuntimeFileOpenPosition | undefined]>([
|
||||
['links without a line', 'darwin', undefined],
|
||||
['a zero line', 'darwin', { line: 0 }],
|
||||
['a fractional line', 'darwin', { line: 1.5 }],
|
||||
['a zero column', 'darwin', { line: 551, column: 0 }],
|
||||
['Linux', 'linux', { line: 551 }],
|
||||
['Windows', 'win32', { line: 551 }]
|
||||
])('leaves %s to the OS opener', async (_name, platform, location) => {
|
||||
setPlatform(platform)
|
||||
|
||||
await expect(openPathInDefaultApp(filePath, location)).resolves.toBe('')
|
||||
expect(applicationInfo).not.toHaveBeenCalled()
|
||||
expect(runProcess).not.toHaveBeenCalled()
|
||||
expect(openPath).toHaveBeenCalledWith(filePath)
|
||||
})
|
||||
})
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { resolveGitCommandWithoutProbe } from './git-command-resolution'
|
||||
import {
|
||||
recordHostAutocrlf,
|
||||
recordLineEndingSample,
|
||||
resetWslWindowsCheckoutGitConfigForTests,
|
||||
wslWindowsCheckoutKey
|
||||
} from './wsl-windows-checkout-git-config'
|
||||
import { resetWslLinkedWorktreeGitRoutingForTests } from './wsl-linked-worktree-git-routing'
|
||||
|
||||
const originalPlatform = process.platform
|
||||
const DRIVE_REPO = String.raw`C:\Users\dev\repo`
|
||||
|
||||
function detectWindowsCheckout(): void {
|
||||
const key = wslWindowsCheckoutKey(DRIVE_REPO, 'Ubuntu')
|
||||
recordHostAutocrlf(key, 'true', Date.now())
|
||||
recordLineEndingSample(key, 'windows', 'clone', Date.now())
|
||||
}
|
||||
|
||||
describe('WSL git on a Windows Git checkout', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
resetWslWindowsCheckoutGitConfigForTests()
|
||||
resetWslLinkedWorktreeGitRoutingForTests()
|
||||
})
|
||||
|
||||
it('runs WSL git with Windows Git line-ending and stat settings once detected', () => {
|
||||
detectWindowsCheckout()
|
||||
|
||||
const resolved = resolveGitCommandWithoutProbe(['add', '--', 'a.ts'], {
|
||||
cwd: DRIVE_REPO,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
|
||||
expect(resolved.binary).toBe('wsl.exe')
|
||||
expect(resolved.args.join(' ')).toMatch(/core\.autocrlf=true.+core\.checkStat=minimal.+add/)
|
||||
expect(resolved.args.join(' ')).not.toContain('trustctime')
|
||||
})
|
||||
})
|
||||
@@ -1,214 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock, spawnMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: vi.fn(),
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
import {
|
||||
awaitWindowsHostGitEnvironmentReady,
|
||||
configureWindowsHostGitEnvironmentReadiness
|
||||
} from './windows-host-git-environment'
|
||||
import { gitExecFileAsync, gitExecFileAsyncBuffer } from '../git-exec-file'
|
||||
import { gitSpawnAfterWindowsEnvironmentReady } from '../git-spawn'
|
||||
import { gitStreamStdout } from '../git-stream-stdout'
|
||||
import { _resetGitAdmissionForTests } from '../admission/git-subprocess-admission'
|
||||
|
||||
type MockChildProcess = EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
pid: number
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createMockChildProcess(pid: number): MockChildProcess {
|
||||
const child = new EventEmitter() as MockChildProcess
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.pid = pid
|
||||
child.kill = vi.fn()
|
||||
return child
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function withPlatform<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
|
||||
const original = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: original })
|
||||
}
|
||||
}
|
||||
|
||||
describe('Windows host Git environment readiness', () => {
|
||||
const originalPath = process.env.Path
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
spawnMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetGitAdmissionForTests()
|
||||
configureWindowsHostGitEnvironmentReadiness(null)
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.Path
|
||||
} else {
|
||||
process.env.Path = originalPath
|
||||
}
|
||||
})
|
||||
|
||||
it('does not gate spawned Git routed through WSL on the Windows host profile', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const waitUntilReady = vi.fn(() => new Promise<void>(() => {}))
|
||||
const child = createMockChildProcess(1234)
|
||||
spawnMock.mockReturnValue(child)
|
||||
execFileMock.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(Object.assign(new Error('missing SSH config'), { code: 1 }), '', '')
|
||||
return child
|
||||
})
|
||||
configureWindowsHostGitEnvironmentReadiness(waitUntilReady)
|
||||
|
||||
await expect(
|
||||
gitSpawnAfterWindowsEnvironmentReady(['clone', 'url', 'repo'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
wslDistro: 'Ubuntu',
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
).resolves.toBe(child)
|
||||
|
||||
expect(waitUntilReady).not.toHaveBeenCalled()
|
||||
expect(spawnMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not spawn Git when canceled during Windows environment readiness', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const ready = deferred()
|
||||
const controller = new AbortController()
|
||||
configureWindowsHostGitEnvironmentReadiness(() => ready.promise)
|
||||
|
||||
const spawned = gitSpawnAfterWindowsEnvironmentReady(['status'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
signal: controller.signal,
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
controller.abort()
|
||||
|
||||
await expect(spawned).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
ready.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels async Git immediately while Windows environment readiness is pending', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const ready = deferred()
|
||||
const controller = new AbortController()
|
||||
configureWindowsHostGitEnvironmentReadiness(() => ready.promise)
|
||||
|
||||
const operation = gitExecFileAsync(['status'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
signal: controller.signal
|
||||
})
|
||||
controller.abort()
|
||||
|
||||
await expect(operation).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
ready.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not gate WSL paths at a synchronous Git consumer boundary', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const waitUntilReady = vi.fn(() => new Promise<void>(() => {}))
|
||||
configureWindowsHostGitEnvironmentReadiness(waitUntilReady)
|
||||
|
||||
await expect(
|
||||
awaitWindowsHostGitEnvironmentReady({
|
||||
cwd: String.raw`\\wsl.localhost\Ubuntu\home\me\repo`
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(waitUntilReady).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not gate Git routed through WSL on the Windows host profile', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const waitUntilReady = vi.fn(() => new Promise<void>(() => {}))
|
||||
const child = createMockChildProcess(1234)
|
||||
execFileMock.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(null, 'ok', '')
|
||||
return child
|
||||
})
|
||||
configureWindowsHostGitEnvironmentReadiness(waitUntilReady)
|
||||
|
||||
await expect(
|
||||
gitExecFileAsync(['status'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
).resolves.toEqual({ stdout: 'ok', stderr: '' })
|
||||
|
||||
expect(waitUntilReady).not.toHaveBeenCalled()
|
||||
expect(execFileMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('gates buffered native Git reads on the current generation', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const ready = deferred()
|
||||
const child = createMockChildProcess(1234)
|
||||
execFileMock.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(null, Buffer.from('blob'), Buffer.alloc(0))
|
||||
return child
|
||||
})
|
||||
configureWindowsHostGitEnvironmentReadiness(() => ready.promise)
|
||||
|
||||
const read = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { cwd: String.raw`C:\repo` })
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
ready.resolve()
|
||||
|
||||
await expect(read).resolves.toEqual({ stdout: Buffer.from('blob') })
|
||||
})
|
||||
})
|
||||
|
||||
it('gates native Git streams and refreshes their snapshotted PATH', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const ready = deferred()
|
||||
const child = createMockChildProcess(1234)
|
||||
spawnMock.mockReturnValue(child)
|
||||
configureWindowsHostGitEnvironmentReadiness(() => ready.promise)
|
||||
process.env.Path = 'hydrated-stream-path'
|
||||
|
||||
const stream = gitStreamStdout(['status'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
env: { Path: 'stale-stream-path' },
|
||||
onStdout: () => {}
|
||||
})
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
ready.resolve()
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
|
||||
expect(spawnMock.mock.calls[0]?.[2]?.env?.Path).toBe('hydrated-stream-path')
|
||||
child.emit('close', 0)
|
||||
|
||||
await expect(stream).resolves.toEqual({ stoppedEarly: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WslResult } from '../../../wsl/execution/wsl-runner'
|
||||
import {
|
||||
pendingWslWindowsCheckoutGitConfig,
|
||||
resetWslWindowsCheckoutProbesForTests,
|
||||
type HostGitResult,
|
||||
type WslWindowsCheckoutProbeDeps
|
||||
} from './wsl-windows-checkout-git-probe'
|
||||
import {
|
||||
resetWslWindowsCheckoutGitConfigForTests,
|
||||
withWslWindowsCheckoutGitConfig
|
||||
} from './wsl-windows-checkout-git-config'
|
||||
import {
|
||||
HOST_CONFIG_SYSTEM_AUTOCRLF,
|
||||
PLAIN_WINDOWS_CLONE_SAMPLE
|
||||
} from './wsl-windows-checkout-line-endings.test-fixture'
|
||||
|
||||
afterEach(() => {
|
||||
resetWslWindowsCheckoutGitConfigForTests()
|
||||
resetWslWindowsCheckoutProbesForTests()
|
||||
})
|
||||
|
||||
const CWD = String.raw`C:\Users\dev\repo`
|
||||
const READ = ['status', '--porcelain=v2']
|
||||
|
||||
function host(overrides: Partial<HostGitResult> = {}): HostGitResult {
|
||||
return { code: 0, stdout: HOST_CONFIG_SYSTEM_AUTOCRLF, stderr: '', timedOut: false, ...overrides }
|
||||
}
|
||||
|
||||
function wsl(overrides: Partial<WslResult> = {}): WslResult {
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: PLAIN_WINDOWS_CLONE_SAMPLE,
|
||||
stderr: '',
|
||||
timedOut: false,
|
||||
outputTruncated: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type HostRunner = NonNullable<WslWindowsCheckoutProbeDeps['runHostGit']>
|
||||
type WslRunner = NonNullable<WslWindowsCheckoutProbeDeps['runWsl']>
|
||||
type IdentityReader = NonNullable<WslWindowsCheckoutProbeDeps['checkoutIdentity']>
|
||||
|
||||
function harness(
|
||||
overrides: { runHostGit?: HostRunner; runWsl?: WslRunner; checkoutIdentity?: IdentityReader } = {}
|
||||
) {
|
||||
const clock = { now: 1_000_000 }
|
||||
const deps = {
|
||||
platform: 'win32' as const,
|
||||
now: () => clock.now,
|
||||
runHostGit: vi.fn<HostRunner>(overrides.runHostGit ?? (async () => host())),
|
||||
runWsl: vi.fn<WslRunner>(overrides.runWsl ?? (async () => wsl())),
|
||||
checkoutIdentity: vi.fn<IdentityReader>(overrides.checkoutIdentity ?? (async () => 'clone-1')),
|
||||
admit: vi.fn(async () => ({ queueWaitMs: 0, release: vi.fn() }))
|
||||
}
|
||||
const run = (args: string[], signal?: AbortSignal) =>
|
||||
pendingWslWindowsCheckoutGitConfig(args, { cwd: CWD, wslDistro: 'Ubuntu', signal }, deps)
|
||||
const flagged = (args: string[]) =>
|
||||
withWslWindowsCheckoutGitConfig(args, CWD, 'Ubuntu', 'win32', clock.now).includes(
|
||||
'core.autocrlf=true'
|
||||
)
|
||||
return { clock, deps, run, flagged }
|
||||
}
|
||||
|
||||
describe('pendingWslWindowsCheckoutGitConfig', () => {
|
||||
it('stops waiting when the command aborts', async () => {
|
||||
const { run } = harness({ runHostGit: vi.fn(() => new Promise<HostGitResult>(() => {})) })
|
||||
const controller = new AbortController()
|
||||
const waiting = run(READ, controller.signal)
|
||||
controller.abort()
|
||||
await expect(waiting).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: execFileSyncMock,
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
import { fakeSpawnReturning } from '../../../../shared/__fixtures__/fake-spawned-child'
|
||||
import { ghExecFileAsync } from './gh-exec-file'
|
||||
import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker'
|
||||
|
||||
const PRIMARY_RATE_LIMIT_STDERR =
|
||||
'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)'
|
||||
|
||||
function mockGhFailure(stderr: string): void {
|
||||
spawnMock.mockImplementation(fakeSpawnReturning({ stderr, code: 1 }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
spawnMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetGhRateLimitBreaker()
|
||||
})
|
||||
|
||||
describe('ghExecFileAsync rate-limit breaker', () => {
|
||||
it('spawns once for a primary 403, then short-circuits same-bucket calls', async () => {
|
||||
mockGhFailure(PRIMARY_RATE_LIMIT_STDERR)
|
||||
await expect(
|
||||
ghExecFileAsync(['api', '--cache', '120s', 'search/issues?q=repo:a/b&per_page=1'])
|
||||
).rejects.toThrow('rate limit')
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await expect(
|
||||
ghExecFileAsync(['api', '--cache', '120s', 'search/issues?q=repo:c/d&per_page=1'])
|
||||
).rejects.toMatchObject({ ghRateLimitBlocked: true })
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: vi.fn(),
|
||||
spawn: vi.fn()
|
||||
}))
|
||||
|
||||
import { gitExecFileAsyncBuffer } from './git-exec-file'
|
||||
|
||||
import { _resetGitAdmissionForTests } from './admission/git-subprocess-admission'
|
||||
import { configureWindowsHostGitEnvironmentReadiness } from './host-routing/windows-host-git-environment'
|
||||
|
||||
beforeEach(() => execFileMock.mockReset())
|
||||
afterEach(() => {
|
||||
_resetGitAdmissionForTests()
|
||||
configureWindowsHostGitEnvironmentReadiness(null)
|
||||
})
|
||||
|
||||
describe('buffered Git cancellation', () => {
|
||||
it('cancels Windows environment readiness without launching Git', async () => {
|
||||
const platform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
try {
|
||||
configureWindowsHostGitEnvironmentReadiness(() => new Promise(() => {}))
|
||||
const controller = new AbortController()
|
||||
const read = gitExecFileAsyncBuffer(['show', 'HEAD:file'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
signal: controller.signal
|
||||
})
|
||||
const rejected = expect(read).rejects.toMatchObject({ name: 'AbortError' })
|
||||
controller.abort()
|
||||
await rejected
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getBranchConflictKind } from './repo-branch-conflict'
|
||||
|
||||
describe('branch conflict real Git contract', () => {
|
||||
const tempPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of tempPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('decides remote conflicts from one batched probe across many remotes', async () => {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'orca-branch-conflict-'))
|
||||
tempPaths.push(repoPath)
|
||||
const git = (...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' })
|
||||
|
||||
git('init', '--quiet')
|
||||
git('config', 'user.name', 'Orca Test')
|
||||
git('config', 'user.email', 'orca@example.test')
|
||||
git('config', 'commit.gpgSign', 'false')
|
||||
git('config', 'core.hooksPath', '.git/no-hooks')
|
||||
writeFileSync(join(repoPath, 'fixture.txt'), 'base\n')
|
||||
git('add', 'fixture.txt')
|
||||
git('commit', '--quiet', '-m', 'base')
|
||||
const head = git('rev-parse', 'HEAD').trim()
|
||||
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
git('remote', 'add', `remote${index}`, 'https://example.test/repo.git')
|
||||
}
|
||||
git('update-ref', 'refs/remotes/remote7/taken', head)
|
||||
|
||||
await expect(getBranchConflictKind(repoPath, 'taken')).resolves.toBe('remote')
|
||||
await expect(getBranchConflictKind(repoPath, 'free')).resolves.toBeNull()
|
||||
await expect(
|
||||
getBranchConflictKind(repoPath, 'taken', 'refs/remotes/remote7/taken')
|
||||
).resolves.toBeNull()
|
||||
|
||||
git('branch', 'local-only', head)
|
||||
await expect(getBranchConflictKind(repoPath, 'local-only')).resolves.toBe('local')
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
const stream = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../command-runner/git-stream-stdout', () => ({ gitStreamStdout: stream }))
|
||||
import { readBranchRefSnapshot } from './branch-ref-snapshot-read'
|
||||
|
||||
afterEach(() => stream.mockReset())
|
||||
|
||||
it('keeps raw output complete under aggregate pressure and distinguishes an intrinsic bound', async () => {
|
||||
stream.mockImplementation(async (_args, options) => {
|
||||
for (const name of ['first', 'second']) {
|
||||
if (options.onStdout(`refs/heads/${name}\0${'a'.repeat(40)}\u00001\0\n`) === true) {
|
||||
return { stoppedEarly: true }
|
||||
}
|
||||
}
|
||||
return { stoppedEarly: false }
|
||||
})
|
||||
const page: string[] = []
|
||||
const pressured = await readBranchRefSnapshot(
|
||||
'/repo',
|
||||
{},
|
||||
(ref) => {
|
||||
page.push(ref.refName)
|
||||
return page.length === 2
|
||||
},
|
||||
undefined,
|
||||
{ reserve: () => false, release: () => {} }
|
||||
)
|
||||
expect(page).toEqual(['refs/heads/first', 'refs/heads/second'])
|
||||
expect(pressured).toEqual({ snapshot: null, oversized: false })
|
||||
expect(await readBranchRefSnapshot('/repo', {}, () => true, 100)).toEqual({
|
||||
snapshot: null,
|
||||
oversized: true
|
||||
})
|
||||
const recovered = await readBranchRefSnapshot('/repo', {}, () => true)
|
||||
expect(recovered.snapshot?.refs.map((ref) => ref.refName)).toEqual([
|
||||
'refs/heads/first',
|
||||
'refs/heads/second'
|
||||
])
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import type * as SnapshotReader from './branch-ref-snapshot-read'
|
||||
import type * as DistroCatalog from '../../../wsl/discovery/distro-catalog'
|
||||
|
||||
const cwd = String.raw`C:\repo-linked`
|
||||
const commonDir = String.raw`C:\repo-main\.git`
|
||||
const gitDir = String.raw`C:\repo-main\.git\worktrees\linked`
|
||||
const io = vi.hoisted(() => ({ remote: vi.fn(), refs: vi.fn() }))
|
||||
vi.mock('../../../wsl/discovery/distro-catalog', async (original) => ({
|
||||
...(await original<typeof DistroCatalog>()),
|
||||
listRunningWslDistrosAsync: async () => ['Ubuntu']
|
||||
}))
|
||||
vi.mock('node:fs/promises', async (original) => ({
|
||||
...(await original<typeof FsPromises>()),
|
||||
realpath: async (path: string) => path,
|
||||
stat: async (path: string) => ({
|
||||
isFile: () => path.endsWith('.git'),
|
||||
isDirectory: () => !path.endsWith('.git'),
|
||||
dev: 1n,
|
||||
ino: 2n,
|
||||
birthtimeNs: 3n
|
||||
}),
|
||||
readFile: async () => String.raw`gitdir: C:\repo-main\.git\worktrees\linked`
|
||||
}))
|
||||
vi.mock('../../../../shared/git/metadata/git-common-directory', () => ({
|
||||
resolveGitCommonDirectory: async () => String.raw`C:\repo-main\.git`
|
||||
}))
|
||||
vi.mock('../../source-control/resolve-git-dir', () => ({
|
||||
resolveGitDir: async () => String.raw`C:\repo-main\.git\worktrees\linked`
|
||||
}))
|
||||
vi.mock('./branch-ref-snapshot-read', async (original) => ({
|
||||
...(await original<typeof SnapshotReader>()),
|
||||
readBranchRemoteNames: io.remote,
|
||||
readBranchRefSnapshot: io.refs
|
||||
}))
|
||||
import { searchBranchRefSnapshot, _resetBranchRefSnapshotsForTests } from './branch-ref-snapshot'
|
||||
import {
|
||||
prepareWslLinkedWorktreeGitRouting,
|
||||
resetWslLinkedWorktreeGitRoutingForTests
|
||||
} from '../../command-runner/host-routing/wsl-linked-worktree-git-routing'
|
||||
import { resolveBranchSnapshotIdentity } from './branch-ref-snapshot-identity'
|
||||
import { trackGitRefMutation } from '../../command-runner/git-ref-mutation-state'
|
||||
import { subscribeGitRefChanges } from '../git-ref-change-signal'
|
||||
import { _resetWslBackgroundDistroGateForTests } from '../../../wsl/discovery/wsl-background-distro-gate'
|
||||
|
||||
const platform = process.platform
|
||||
afterEach(() => {
|
||||
_resetBranchRefSnapshotsForTests()
|
||||
resetWslLinkedWorktreeGitRoutingForTests()
|
||||
_resetWslBackgroundDistroGateForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
vi.restoreAllMocks()
|
||||
io.remote.mockReset()
|
||||
io.refs.mockReset()
|
||||
})
|
||||
|
||||
it('keeps a live host snapshot across expiry of the filesystem-only linked-route proof', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(10_000)
|
||||
// The reader boundary models execution's existing route settlement; it is not a real WSL benchmark.
|
||||
io.remote.mockImplementation(async () => {
|
||||
await prepareWslLinkedWorktreeGitRouting(cwd, 'Ubuntu')
|
||||
return []
|
||||
})
|
||||
io.refs.mockResolvedValue({
|
||||
snapshot: {
|
||||
refs: [
|
||||
{
|
||||
refName: 'refs/heads/topic',
|
||||
label: 'topic',
|
||||
searchName: 'topic',
|
||||
oid: 'a'.repeat(40),
|
||||
committedAt: 0,
|
||||
symref: ''
|
||||
}
|
||||
]
|
||||
},
|
||||
oversized: false
|
||||
})
|
||||
expect(await searchBranchRefSnapshot(cwd, 'topic', 20, { wslDistro: 'Ubuntu' })).toEqual([
|
||||
{ refName: 'refs/heads/topic', localBranchName: 'topic' }
|
||||
])
|
||||
clock.mockReturnValue(40_001)
|
||||
expect(await resolveBranchSnapshotIdentity(cwd, { wslDistro: 'Ubuntu' })).toMatchObject({
|
||||
commonDir,
|
||||
gitDir
|
||||
})
|
||||
expect(await searchBranchRefSnapshot(cwd, 'topic', 20, { wslDistro: 'Ubuntu' })).toEqual([
|
||||
{ refName: 'refs/heads/topic', localBranchName: 'topic' }
|
||||
])
|
||||
expect(io.remote).toHaveBeenCalledTimes(1)
|
||||
expect(io.refs).toHaveBeenCalledTimes(1)
|
||||
clock.mockReturnValue(70_002)
|
||||
const events: string[] = []
|
||||
const offOwner = subscribeGitRefChanges(commonDir, () => events.push('owner'))
|
||||
const offOther = subscribeGitRefChanges(String.raw`C:\other\.git`, () => events.push('other'))
|
||||
try {
|
||||
trackGitRefMutation(['commit'])()
|
||||
expect(events).toEqual(['owner', 'other', 'owner', 'other'])
|
||||
} finally {
|
||||
offOwner()
|
||||
offOther()
|
||||
}
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as gitExecFileModule from '../../command-runner/git-exec-file'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { getGitRepoRootAsync, isGitRepoAsync } from './repo-detection'
|
||||
|
||||
let fixture: string
|
||||
async function git(cwd: string, args: string[]): Promise<void> {
|
||||
const result = await runProcess({ program: 'git', args, cwd, timeoutMs: 15_000 })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = mkdtempSync(join(tmpdir(), 'orca-repo-async-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
rmSync(fixture, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('asynchronous repository detection with real Git', () => {
|
||||
it('preserves genuine metadata fallback when asynchronous Git cannot answer', async () => {
|
||||
const repo = join(fixture, 'fallback repo')
|
||||
const nested = join(repo, 'packages')
|
||||
mkdirSync(nested, { recursive: true })
|
||||
await git(repo, ['init', '--quiet'])
|
||||
vi.spyOn(gitExecFileModule, 'gitExecFileAsync').mockRejectedValue(new Error('Git unavailable'))
|
||||
expect(await isGitRepoAsync(nested)).toBe(true)
|
||||
expect(await getGitRepoRootAsync(nested)).toBe(repo.replaceAll('\\', '/'))
|
||||
expect(await isGitRepoAsync(join(repo, '.git'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import type * as GitExecFileModule from '../../command-runner/git-exec-file'
|
||||
import type * as GitStreamStdoutModule from '../../command-runner/git-stream-stdout'
|
||||
import type * as GitProcessEnvModule from '../../command-runner/git-environment/git-process-env'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as BoundedFileReader from '../../../../shared/file-content/node-bounded-file-reader'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
createBoundedFileReaderModuleMock,
|
||||
createFsPromisesModuleMock,
|
||||
createGitExecFileModuleMock,
|
||||
createGitStreamStdoutModuleMock,
|
||||
createGitProcessEnvModuleMock
|
||||
} from './status-test-harness'
|
||||
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
gitExecFileAsyncBufferMock,
|
||||
gitStreamOptionsMock,
|
||||
lstatMock,
|
||||
realpathMock,
|
||||
readFileMock,
|
||||
statMock,
|
||||
rmMock,
|
||||
existsSyncMock
|
||||
} = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileAsyncBufferMock: vi.fn(),
|
||||
gitStreamOptionsMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
realpathMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
statMock: vi.fn(),
|
||||
rmMock: vi.fn(),
|
||||
existsSyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../command-runner/git-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitExecFileModule>()),
|
||||
...createGitExecFileModuleMock({ gitExecFileAsyncMock, gitExecFileAsyncBufferMock })
|
||||
}))
|
||||
|
||||
vi.mock('../../command-runner/git-stream-stdout', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitStreamStdoutModule>()),
|
||||
...createGitStreamStdoutModuleMock({ gitExecFileAsyncMock, gitStreamOptionsMock })
|
||||
}))
|
||||
|
||||
vi.mock('../../command-runner/git-environment/git-process-env', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitProcessEnvModule>()),
|
||||
...createGitProcessEnvModuleMock()
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () =>
|
||||
createFsPromisesModuleMock({ lstatMock, realpathMock, readFileMock, statMock, rmMock })
|
||||
)
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: existsSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../../shared/file-content/node-bounded-file-reader', async (importOriginal) =>
|
||||
createBoundedFileReaderModuleMock(await importOriginal<typeof BoundedFileReader>(), {
|
||||
readFileMock,
|
||||
statMock
|
||||
})
|
||||
)
|
||||
import { getDiff } from './file-diff'
|
||||
describe('getDiff', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncBufferMock.mockReset()
|
||||
lstatMock.mockReset()
|
||||
readFileMock.mockReset()
|
||||
statMock.mockReset()
|
||||
existsSyncMock.mockReset()
|
||||
statMock.mockResolvedValue({
|
||||
isFile: () => true,
|
||||
size: 12
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat an unreadable working-tree image as a deletion', async () => {
|
||||
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])
|
||||
const workingTreePath = path.join('/repo', 'assets/unreadable.png')
|
||||
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer })
|
||||
statMock.mockImplementation(async () => ({ isFile: () => true, size: 5 }))
|
||||
readFileMock.mockImplementation(async (target: string) => {
|
||||
if (target === workingTreePath) {
|
||||
throw new Error('EIO')
|
||||
}
|
||||
return Buffer.from('')
|
||||
})
|
||||
|
||||
const result = await getDiff('/repo', 'assets/unreadable.png', false)
|
||||
|
||||
expect(result.kind).toBe('binary')
|
||||
if (result.kind !== 'binary') {
|
||||
throw new Error('expected binary diff result')
|
||||
}
|
||||
expect(result.modifiedDeleted).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parsePorcelainV1Records } from './porcelain-v1-records'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
const git = (args: string[], cwd: string): string =>
|
||||
execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
|
||||
function createRepo(): string {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'orca-porcelain-v1-'))
|
||||
tempRoots.push(repo)
|
||||
git(['init', '-q', '-b', 'main'], repo)
|
||||
git(['config', 'user.email', 'test@example.com'], repo)
|
||||
git(['config', 'user.name', 'Test'], repo)
|
||||
return repo
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('parsePorcelainV1Records', () => {
|
||||
it('reports a rename alongside a later untracked entry', () => {
|
||||
const repo = createRepo()
|
||||
writeFileSync(join(repo, 'original.txt'), 'content\n')
|
||||
git(['add', '-A'], repo)
|
||||
git(['commit', '-qm', 'init'], repo)
|
||||
git(['mv', 'original.txt', 'renamed.txt'], repo)
|
||||
mkdirSync(join(repo, 'node_modules'))
|
||||
writeFileSync(join(repo, 'node_modules', 'pkg.js'), 'x')
|
||||
|
||||
const records = parsePorcelainV1Records(git(['status', '--porcelain', '-z'], repo))
|
||||
|
||||
expect(records).toEqual([
|
||||
{ xy: 'R ', path: 'renamed.txt' },
|
||||
{ xy: '??', path: 'node_modules/' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../../../../shared/git/status/git-branch-line-total'
|
||||
|
||||
import { clearEffectiveUpstreamStatusCacheForTests } from '../upstream/effective-upstream-status-cache'
|
||||
import { clearSubmodulePathsCacheForTests } from '../submodules/submodule-paths'
|
||||
import { getStatus } from './status-read'
|
||||
import { invalidateGitReadCaches } from '../git-read-cache-invalidation'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
function git(repo: string, args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
}
|
||||
|
||||
async function createFixtureRepo(): Promise<{ repo: string; mergeBase: string }> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-exec-'))
|
||||
tempRoots.push(root)
|
||||
const repo = path.join(root, 'repo')
|
||||
execFileSync('git', ['init', '-q', repo])
|
||||
git(repo, ['config', 'user.email', 'test@example.com'])
|
||||
git(repo, ['config', 'user.name', 'Test User'])
|
||||
git(repo, ['config', 'commit.gpgSign', 'false'])
|
||||
await write(repo, 'f.txt', 'a\nb\n')
|
||||
git(repo, ['add', '-A'])
|
||||
git(repo, ['commit', '-q', '-m', 'base'])
|
||||
await write(repo, 'f.txt', 'a\nb\nc\n')
|
||||
return { repo, mergeBase: git(repo, ['rev-parse', 'HEAD']) }
|
||||
}
|
||||
|
||||
async function write(repo: string, relativePath: string, contents: string): Promise<void> {
|
||||
const target = path.join(repo, relativePath)
|
||||
await mkdir(path.dirname(target), { recursive: true })
|
||||
await writeFile(target, contents)
|
||||
}
|
||||
|
||||
function resetGitReadCaches(): void {
|
||||
clearEffectiveUpstreamStatusCacheForTests()
|
||||
clearSubmodulePathsCacheForTests()
|
||||
invalidateGitReadCaches()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetGitReadCaches()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetGitReadCaches()
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('branch line total completeness', () => {
|
||||
it('omits the total when the status listing hit its limit', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
await write(repo, 'u1.txt', 'a\n')
|
||||
await write(repo, 'u2.txt', 'b\n')
|
||||
await write(repo, 'u3.txt', 'c\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 1 })
|
||||
|
||||
expect(result.didHitLimit).toBe(true)
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
expect(result.branchLineTotalUnavailable).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
// Untracked line counts use Node I/O, so WSL guest paths need Win32 spelling while host paths stay literal.
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import type * as BoundedFileReader from '../../../../shared/file-content/node-bounded-file-reader'
|
||||
import type { GitStatusEntry } from '../../../../shared/git/status/git-status-types'
|
||||
|
||||
const { lstatPaths, untrackedFiles } = vi.hoisted(() => ({
|
||||
lstatPaths: [] as string[],
|
||||
untrackedFiles: new Map<string, string>()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof FsPromises>()
|
||||
return {
|
||||
...actual,
|
||||
lstat: async (target: string) => {
|
||||
lstatPaths.push(target)
|
||||
const contents = untrackedFiles.get(target)
|
||||
if (contents === undefined) {
|
||||
throw Object.assign(new Error(`ENOENT: ${target}`), { code: 'ENOENT' })
|
||||
}
|
||||
return {
|
||||
size: Buffer.byteLength(contents),
|
||||
// Distinct per read so the stat-keyed untracked cache never serves another case's entry.
|
||||
mtimeMs: lstatPaths.length,
|
||||
ctimeMs: lstatPaths.length,
|
||||
isSymbolicLink: () => false,
|
||||
isFile: () => true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../../shared/file-content/node-bounded-file-reader', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof BoundedFileReader>()
|
||||
return {
|
||||
...actual,
|
||||
readNodeFileWithinLimit: async (target: string) => {
|
||||
const contents = untrackedFiles.get(target)
|
||||
if (contents === undefined) {
|
||||
throw Object.assign(new Error(`ENOENT: ${target}`), { code: 'ENOENT' })
|
||||
}
|
||||
return { buffer: Buffer.from(contents) }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { attachLineStats } from './status-line-stats'
|
||||
|
||||
const GUEST_WORKTREE = '/home/me/repo'
|
||||
const UNC_WORKTREE = String.raw`\\wsl.localhost\Ubuntu\home\me\repo`
|
||||
|
||||
function untrackedEntry(entryPath: string): GitStatusEntry {
|
||||
return { path: entryPath, area: 'untracked', status: 'added' }
|
||||
}
|
||||
|
||||
describe('untracked line stats on a WSL worktree read by a Windows host', () => {
|
||||
beforeEach(() => {
|
||||
lstatPaths.length = 0
|
||||
untrackedFiles.clear()
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('re-spells the guest form only, leaving every host-openable spelling byte-identical', async () => {
|
||||
const cases: { worktree: string; expected: string }[] = [
|
||||
{ worktree: GUEST_WORKTREE, expected: UNC_WORKTREE },
|
||||
{ worktree: UNC_WORKTREE, expected: UNC_WORKTREE },
|
||||
{
|
||||
worktree: '//wsl.localhost/Ubuntu/home/me/repo',
|
||||
expected: '//wsl.localhost/Ubuntu/home/me/repo'
|
||||
},
|
||||
{ worktree: '/home/me/my repo ', expected: '/home/me/my repo ' }
|
||||
]
|
||||
|
||||
for (const { worktree, expected } of cases) {
|
||||
lstatPaths.length = 0
|
||||
untrackedFiles.clear()
|
||||
const target = path.join(expected, 'fresh.txt')
|
||||
untrackedFiles.set(target, 'one\ntwo\n')
|
||||
const entries = [untrackedEntry('fresh.txt')]
|
||||
|
||||
await attachLineStats(worktree, entries, { wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(entries[0].added, worktree).toBe(2)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { GitPushTarget } from '../../../../shared/worktree/types'
|
||||
import { getUpstreamStatus } from './upstream'
|
||||
|
||||
// Real Git verifies missing-ref exit/stderr handling before a fork remote is materialized.
|
||||
describe('getUpstreamStatus with a deferred (not-yet-materialized) fork remote', () => {
|
||||
const tempPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of tempPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports the graceful "publish" state instead of 0 ahead/0 behind', async () => {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'orca-deferred-fork-remote-'))
|
||||
tempPaths.push(repoPath)
|
||||
const git = (...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' })
|
||||
|
||||
git('init', '--quiet')
|
||||
git('config', 'user.name', 'Orca Test')
|
||||
git('config', 'user.email', 'orca@example.test')
|
||||
git('config', 'commit.gpgSign', 'false')
|
||||
git('config', 'core.hooksPath', '.git/no-hooks')
|
||||
writeFileSync(join(repoPath, 'fixture.txt'), 'base\n')
|
||||
git('add', 'fixture.txt')
|
||||
git('commit', '-m', 'base')
|
||||
git('branch', '-M', 'contributor/fix')
|
||||
|
||||
// Persist a push target without adding its deferred remote.
|
||||
const pushTarget: GitPushTarget = {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'contributor/fix',
|
||||
remoteUrl: 'git@github.com:contributor/orca.git'
|
||||
}
|
||||
|
||||
const status = await getUpstreamStatus(repoPath, pushTarget)
|
||||
|
||||
expect(status).toEqual({
|
||||
hasUpstream: false,
|
||||
upstreamName: 'pr-contributor-orca/contributor/fix',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasConfiguredPushTarget: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,155 +0,0 @@
|
||||
import type * as GithubRepositoryIdentityModule from '../../repositories/github-repository-identity'
|
||||
import type * as GitExecFileModule from '../../../git/command-runner/git-exec-file'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
getOwnerRepoForRemoteMock,
|
||||
getEnterpriseGitHubRepoSlugMock,
|
||||
extractExecErrorMock,
|
||||
acquireMock,
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
getOwnerRepoForRemoteMock: vi.fn(),
|
||||
getEnterpriseGitHubRepoSlugMock: vi.fn(),
|
||||
extractExecErrorMock: vi.fn((error: unknown) => {
|
||||
const value = error as { stderr?: string; stdout?: string; message?: string }
|
||||
return {
|
||||
stderr: value?.stderr ?? value?.message ?? '',
|
||||
stdout: value?.stdout ?? ''
|
||||
}
|
||||
}),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../execution/github-legacy-command', () => ({
|
||||
execFileAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/command-runner/hosted-cli/gh-exec-file', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../../repositories/github-owner-repo-selection', () => ({
|
||||
getOwnerRepo: getOwnerRepoMock
|
||||
}))
|
||||
|
||||
vi.mock('../../repositories/github-repository-identity', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GithubRepositoryIdentityModule>()),
|
||||
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
|
||||
githubRepoContext: vi.fn((repoPath: string, connectionId?: string | null, options = {}) => ({
|
||||
...options,
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null
|
||||
})),
|
||||
ghRepoExecOptions: vi.fn(
|
||||
(context: { repoPath: string; connectionId?: string | null; wslDistro?: string }) =>
|
||||
context.connectionId
|
||||
? {}
|
||||
: { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) }
|
||||
),
|
||||
_resetOwnerRepoCache: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/command-runner/git-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitExecFileModule>()),
|
||||
gitExecFileAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../execution/github-operation-slots', () => ({
|
||||
acquire: acquireMock,
|
||||
release: releaseMock
|
||||
}))
|
||||
|
||||
vi.mock('../../repositories/github-enterprise-repository', () => ({
|
||||
getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock,
|
||||
isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
import { createGitHubPullRequest } from './create-github-pull-request'
|
||||
|
||||
import { _resetOriginGitHubApiRepositoryCache } from '../../repositories/github-api-repository-remote-probe'
|
||||
|
||||
beforeEach(() => {
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
})
|
||||
|
||||
describe('createGitHubPullRequest', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
getOwnerRepoMock.mockReset()
|
||||
getOwnerRepoForRemoteMock.mockReset()
|
||||
// Bridge explicit-origin creation to the fixtures that define getOwnerRepoMock.
|
||||
getOwnerRepoForRemoteMock.mockImplementation(
|
||||
async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) =>
|
||||
remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null
|
||||
)
|
||||
getEnterpriseGitHubRepoSlugMock.mockReset()
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null)
|
||||
extractExecErrorMock.mockClear()
|
||||
acquireMock.mockReset()
|
||||
releaseMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('returns the existing PR when gh reports an already-open pull request', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce({ stderr: 'a pull request already exists for feature/existing' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 44,
|
||||
url: 'https://github.com/acme/widgets/pull/44'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
await expect(
|
||||
createGitHubPullRequest(
|
||||
'/repo-root',
|
||||
{
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'refs/remotes/origin/feature/existing',
|
||||
title: 'Existing'
|
||||
},
|
||||
'local'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
code: 'already_exists',
|
||||
error: 'A pull request already exists for this branch.',
|
||||
reason: 'existing_review',
|
||||
existingReview: {
|
||||
number: 44,
|
||||
url: 'https://github.com/acme/widgets/pull/44'
|
||||
}
|
||||
})
|
||||
|
||||
expect(ghExecFileAsyncMock.mock.calls[1]).toEqual([
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--head',
|
||||
'feature/existing',
|
||||
'--base',
|
||||
'main',
|
||||
'--state',
|
||||
'open',
|
||||
'--limit',
|
||||
'2',
|
||||
'--json',
|
||||
'number,url'
|
||||
],
|
||||
// Pin dotcom against process-level GH_HOST.
|
||||
{ cwd: '/repo-root', host: 'github.com' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,206 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { exec, guard, spend } = vi.hoisted(() => ({ exec: vi.fn(), guard: vi.fn(), spend: vi.fn() }))
|
||||
vi.mock('../../../git/command-runner/hosted-cli/gh-exec-file', () => ({
|
||||
ghExecFileAsync: exec
|
||||
}))
|
||||
vi.mock('../../api/rate-limit', () => ({
|
||||
repositoryRateLimitGuard: guard,
|
||||
noteRepositoryRateLimitSpend: spend
|
||||
}))
|
||||
|
||||
import { detectRepositoryMergeMetadata } from './repository-merge-metadata'
|
||||
import { _resetMergeQueueCacheForTests } from './repository-merge-metadata-cache'
|
||||
|
||||
const repository = { owner: 'acme', repo: 'widgets', host: 'github.com' }
|
||||
const options = { cwd: '/repo' }
|
||||
|
||||
function response(autoMergeAllowed: boolean): { stdout: string } {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
data: { repository: { autoMergeAllowed, mergeQueue: null, ref: { rules: { nodes: [] } } } }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.stubEnv('GH_CONFIG_DIR', undefined)
|
||||
vi.stubEnv('XDG_CONFIG_HOME', undefined)
|
||||
_resetMergeQueueCacheForTests()
|
||||
guard.mockReturnValue({ blocked: false })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('repository merge metadata reuse', () => {
|
||||
it.each([true, false])(
|
||||
'retains a successful concurrent read when a newer read fails (failure first: %s)',
|
||||
async (failureFirst) => {
|
||||
const older = Promise.withResolvers<{ stdout: string }>()
|
||||
const newer = Promise.withResolvers<{ stdout: string }>()
|
||||
exec.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise)
|
||||
const first = detectRepositoryMergeMetadata(repository, 'main', options, 'local:host')
|
||||
const second = detectRepositoryMergeMetadata(
|
||||
repository,
|
||||
'main',
|
||||
{ cwd: '/another-worktree' },
|
||||
'local:host'
|
||||
)
|
||||
if (failureFirst) {
|
||||
newer.reject(new Error('ECONNRESET'))
|
||||
await second
|
||||
older.resolve(response(true))
|
||||
await first
|
||||
} else {
|
||||
older.resolve(response(true))
|
||||
await first
|
||||
newer.reject(new Error('ECONNRESET'))
|
||||
await second
|
||||
}
|
||||
await expect(
|
||||
detectRepositoryMergeMetadata(repository, 'main', options, 'local:host')
|
||||
).resolves.toMatchObject({ autoMergeAllowed: true })
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not couple independent abort signals or cache an aborted result', async () => {
|
||||
const canceled = Promise.withResolvers<{ stdout: string }>()
|
||||
const surviving = Promise.withResolvers<{ stdout: string }>()
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
exec.mockReturnValueOnce(canceled.promise).mockReturnValueOnce(surviving.promise)
|
||||
const first = detectRepositoryMergeMetadata(
|
||||
repository,
|
||||
'main',
|
||||
{ ...options, signal: firstController.signal },
|
||||
'local:host'
|
||||
)
|
||||
const second = detectRepositoryMergeMetadata(
|
||||
repository,
|
||||
'main',
|
||||
{ ...options, signal: secondController.signal },
|
||||
'local:host'
|
||||
)
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
firstController.abort()
|
||||
canceled.reject(firstController.signal.reason)
|
||||
await expect(first).resolves.toMatchObject({ autoMergeAllowed: null })
|
||||
surviving.resolve(response(false))
|
||||
await expect(second).resolves.toMatchObject({ autoMergeAllowed: false })
|
||||
await expect(
|
||||
detectRepositoryMergeMetadata(repository, 'main', options, 'local:host')
|
||||
).resolves.toMatchObject({ autoMergeAllowed: false })
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries immediately with a new signal after the only probe aborts', async () => {
|
||||
const canceled = Promise.withResolvers<{ stdout: string }>()
|
||||
const controller = new AbortController()
|
||||
exec.mockReturnValueOnce(canceled.promise).mockResolvedValueOnce(response(true))
|
||||
const first = detectRepositoryMergeMetadata(
|
||||
repository,
|
||||
'main',
|
||||
{ ...options, signal: controller.signal },
|
||||
'local:host'
|
||||
)
|
||||
controller.abort()
|
||||
canceled.reject(controller.signal.reason)
|
||||
await first
|
||||
|
||||
await expect(
|
||||
detectRepositoryMergeMetadata(
|
||||
repository,
|
||||
'main',
|
||||
{ ...options, signal: new AbortController().signal },
|
||||
'local:host'
|
||||
)
|
||||
).resolves.toMatchObject({ autoMergeAllowed: true })
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains a surviving cache writer when a newer independent probe aborts', async () => {
|
||||
const older = Promise.withResolvers<{ stdout: string }>()
|
||||
const canceled = Promise.withResolvers<{ stdout: string }>()
|
||||
const controller = new AbortController()
|
||||
exec.mockReturnValueOnce(older.promise).mockReturnValueOnce(canceled.promise)
|
||||
const first = detectRepositoryMergeMetadata(repository, 'main', options, 'local:host')
|
||||
const second = detectRepositoryMergeMetadata(
|
||||
repository,
|
||||
'main',
|
||||
{ ...options, signal: controller.signal },
|
||||
'local:host'
|
||||
)
|
||||
controller.abort()
|
||||
canceled.reject(controller.signal.reason)
|
||||
await second
|
||||
older.resolve(response(false))
|
||||
await first
|
||||
|
||||
await expect(
|
||||
detectRepositoryMergeMetadata(repository, 'main', options, 'local:host')
|
||||
).resolves.toMatchObject({ autoMergeAllowed: false })
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not spend quota for an already aborted probe', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
exec.mockResolvedValue(response(true))
|
||||
|
||||
await expect(
|
||||
detectRepositoryMergeMetadata(
|
||||
repository,
|
||||
'main',
|
||||
{ ...options, signal: controller.signal },
|
||||
'local:host'
|
||||
)
|
||||
).resolves.toMatchObject({ autoMergeAllowed: null })
|
||||
expect(exec).not.toHaveBeenCalled()
|
||||
expect(spend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps newer worktree settings when an older directory probe completes later', async () => {
|
||||
const older = Promise.withResolvers<{ stdout: string }>()
|
||||
const newer = Promise.withResolvers<{ stdout: string }>()
|
||||
exec.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise)
|
||||
const first = detectRepositoryMergeMetadata(repository, 'main', options, 'local:host')
|
||||
const second = detectRepositoryMergeMetadata(
|
||||
repository,
|
||||
'main',
|
||||
{ cwd: '/another-worktree' },
|
||||
'local:host'
|
||||
)
|
||||
|
||||
newer.resolve(response(false))
|
||||
await expect(second).resolves.toMatchObject({ autoMergeAllowed: false })
|
||||
older.resolve(response(true))
|
||||
await expect(first).resolves.toMatchObject({ autoMergeAllowed: true })
|
||||
await expect(
|
||||
detectRepositoryMergeMetadata(repository, 'main', options, 'local:host')
|
||||
).resolves.toMatchObject({ autoMergeAllowed: false })
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('allows a stale successor without letting the older completion replace its cache', async () => {
|
||||
vi.useFakeTimers()
|
||||
const older = Promise.withResolvers<{ stdout: string }>()
|
||||
const newer = Promise.withResolvers<{ stdout: string }>()
|
||||
exec.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise)
|
||||
const first = detectRepositoryMergeMetadata(repository, 'main', options, 'local:host')
|
||||
vi.advanceTimersByTime(60_000)
|
||||
const second = detectRepositoryMergeMetadata(repository, 'main', options, 'local:host')
|
||||
|
||||
newer.resolve(response(false))
|
||||
await expect(second).resolves.toMatchObject({ autoMergeAllowed: false })
|
||||
older.resolve(response(true))
|
||||
await expect(first).resolves.toMatchObject({ autoMergeAllowed: true })
|
||||
await expect(
|
||||
detectRepositoryMergeMetadata(repository, 'main', options, 'local:host')
|
||||
).resolves.toMatchObject({ autoMergeAllowed: false })
|
||||
expect(exec).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,167 +0,0 @@
|
||||
import type * as GithubRepositoryIdentityModule from '../../repositories/github-repository-identity'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { ghExecFileAsyncMock, getOwnerRepoMock, rateLimitGuardMock } = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
rateLimitGuardMock: vi.fn(() => ({ blocked: false }))
|
||||
}))
|
||||
|
||||
vi.mock('../../execution/github-legacy-command', () => ({
|
||||
execFileAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/command-runner/hosted-cli/gh-exec-file', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../../repositories/github-repository-identity', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GithubRepositoryIdentityModule>()),
|
||||
githubRepoContext: (repoPath: string, connectionId?: string | null) => ({
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null
|
||||
}),
|
||||
ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }),
|
||||
getOwnerRepoForRemote: (
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) =>
|
||||
remoteName === 'origin'
|
||||
? getOwnerRepoMock(repoPath, connectionId, localGitOptions)
|
||||
: Promise.resolve(null),
|
||||
_resetOwnerRepoCache: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../repositories/github-owner-repo-selection', () => ({
|
||||
getOwnerRepo: getOwnerRepoMock,
|
||||
resolveIssueSource: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/command-runner/process-capture/exec-error', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
extractExecError: vi.fn((err: unknown) => ({ stderr: String(err), stdout: '' }))
|
||||
}))
|
||||
|
||||
vi.mock('../../execution/github-operation-slots', () => ({
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../api/gh-error-classification', () => ({
|
||||
classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }),
|
||||
classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr })
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/command-runner/git-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
gitExecFileAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../api/rate-limit', () => ({
|
||||
rateLimitGuard: rateLimitGuardMock,
|
||||
noteRateLimitSpend: vi.fn(),
|
||||
getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })),
|
||||
repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })),
|
||||
noteRepositoryRateLimitSpend: vi.fn(),
|
||||
spendsSharedGitHubComQuota: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
import { getWorkItem } from './get-work-item'
|
||||
import { _resetMergeQueueCacheForTests } from '../detect/repository-merge-metadata-cache'
|
||||
import { _resetOwnerRepoCache } from '../../repositories/github-repository-identity'
|
||||
import { _resetOriginGitHubApiRepositoryCache } from '../../repositories/github-api-repository-remote-probe'
|
||||
|
||||
// Metadata and complete checks are independent provider responses.
|
||||
function mockPullRequestDetail(payload: Record<string, unknown>): void {
|
||||
const head = 'a'.repeat(40)
|
||||
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'pr' && args[1] === 'view') {
|
||||
return { stdout: JSON.stringify({ ...payload, headRefOid: head }) }
|
||||
}
|
||||
if (args.includes('graphql') && args.some((arg) => arg.includes('statusCheckRollup'))) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
number: payload.number,
|
||||
headRefOid: head,
|
||||
commits: {
|
||||
nodes: [
|
||||
{
|
||||
commit: {
|
||||
oid: head,
|
||||
statusCheckRollup: {
|
||||
contexts: {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these test payloads contain object rollup rows only.
|
||||
nodes: (payload.statusCheckRollup as Record<string, unknown>[]).map(
|
||||
(raw) => ({
|
||||
...raw,
|
||||
__typename:
|
||||
raw.__typename ?? (raw.state ? 'StatusContext' : 'CheckRun'),
|
||||
...(!raw.state ? { checkSuite: { databaseId: 7 } } : {})
|
||||
})
|
||||
),
|
||||
pageInfo: { hasNextPage: false, endCursor: null }
|
||||
}
|
||||
},
|
||||
checkSuites: {
|
||||
nodes: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
throw new Error('unexpected gh call')
|
||||
})
|
||||
}
|
||||
|
||||
describe('work item checksSummary', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
getOwnerRepoMock.mockReset()
|
||||
rateLimitGuardMock.mockReset()
|
||||
rateLimitGuardMock.mockReturnValue({ blocked: false })
|
||||
_resetOwnerRepoCache()
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
_resetMergeQueueCacheForTests()
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
})
|
||||
|
||||
it('counts a skipped run as passing and reads a StatusContext state as its conclusion', async () => {
|
||||
mockPullRequestDetail({
|
||||
number: 42,
|
||||
title: 'Add feature',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/acme/widgets/pull/42',
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
statusCheckRollup: [
|
||||
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SKIPPED' },
|
||||
// StatusContext supplies state instead of CheckRun's status/conclusion.
|
||||
{ __typename: 'StatusContext', state: 'SUCCESS' },
|
||||
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'NEUTRAL' }
|
||||
]
|
||||
})
|
||||
|
||||
const item = await getWorkItem('/repo-root', 42, 'pr', null, {}, 'origin')
|
||||
|
||||
expect(item?.checksSummary).toEqual({
|
||||
state: 'success',
|
||||
total: 3,
|
||||
passed: 2,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
pending: 0,
|
||||
neutral: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,203 +0,0 @@
|
||||
import type * as GithubLegacyCommandModule from '../../execution/github-legacy-command'
|
||||
import type * as GhExecFileModule from '../../../git/command-runner/hosted-cli/gh-exec-file'
|
||||
import type * as GithubOwnerRepoSelectionModule from '../../repositories/github-owner-repo-selection'
|
||||
import type * as GithubRepositoryIdentityModule from '../../repositories/github-repository-identity'
|
||||
import type * as GithubOperationSlotsModule from '../../execution/github-operation-slots'
|
||||
import type * as GithubApiRepositoryModule from '../../repositories/github-api-repository'
|
||||
import type * as GithubApiRepositoryRemoteProbeModule from '../../repositories/github-api-repository-remote-probe'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
getIssueOwnerRepoMock,
|
||||
getOwnerRepoForRemoteMock,
|
||||
resolvePRRepositoryCandidatesMock,
|
||||
resolveIssueSourceMock,
|
||||
rateLimitGuardMock,
|
||||
noteRateLimitSpendMock,
|
||||
acquireMock,
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
getIssueOwnerRepoMock: vi.fn(),
|
||||
getOwnerRepoForRemoteMock: vi.fn(),
|
||||
resolvePRRepositoryCandidatesMock: vi.fn(),
|
||||
resolveIssueSourceMock: vi.fn(),
|
||||
rateLimitGuardMock: vi.fn(() => ({ blocked: false })),
|
||||
noteRateLimitSpendMock: vi.fn(),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../execution/github-legacy-command', async () => {
|
||||
const actual = await vi.importActual<typeof GithubLegacyCommandModule>(
|
||||
'../../execution/github-legacy-command'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
execFileAsync: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../git/command-runner/hosted-cli/gh-exec-file', async () => {
|
||||
const actual = await vi.importActual<typeof GhExecFileModule>(
|
||||
'../../../git/command-runner/hosted-cli/gh-exec-file'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
ghExecFileAsync: ghExecFileAsyncMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../repositories/github-owner-repo-selection', async () => {
|
||||
const actual = await vi.importActual<typeof GithubOwnerRepoSelectionModule>(
|
||||
'../../repositories/github-owner-repo-selection'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
getOwnerRepo: getOwnerRepoMock,
|
||||
resolveIssueSource: resolveIssueSourceMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../repositories/github-repository-identity', async () => {
|
||||
const actual = await vi.importActual<typeof GithubRepositoryIdentityModule>(
|
||||
'../../repositories/github-repository-identity'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
|
||||
_resetOwnerRepoCache: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../execution/github-operation-slots', async () => {
|
||||
const actual = await vi.importActual<typeof GithubOperationSlotsModule>(
|
||||
'../../execution/github-operation-slots'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
acquire: acquireMock,
|
||||
release: releaseMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../api/rate-limit', () => ({
|
||||
rateLimitGuard: rateLimitGuardMock,
|
||||
noteRateLimitSpend: noteRateLimitSpendMock,
|
||||
getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })),
|
||||
repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })),
|
||||
noteRepositoryRateLimitSpend: vi.fn(),
|
||||
spendsSharedGitHubComQuota: () => true
|
||||
}))
|
||||
|
||||
vi.mock('../../repositories/github-api-repository', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal<typeof GithubApiRepositoryModule>()),
|
||||
resolveIssueGitHubApiRepositorySource: (
|
||||
repoPath: string,
|
||||
preference: unknown,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions),
|
||||
getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) =>
|
||||
getIssueOwnerRepoMock(repoPath, connectionId),
|
||||
resolveGitHubApiRepositoryCandidates: (
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../repositories/github-api-repository-remote-probe', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal<typeof GithubApiRepositoryRemoteProbeModule>()),
|
||||
getOriginGitHubApiRepository: (
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => getOwnerRepoMock(repoPath, connectionId, localGitOptions),
|
||||
getGitHubApiRepositoryForRemote: (
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) =>
|
||||
remoteName === 'origin'
|
||||
? getOwnerRepoMock(repoPath, connectionId, localGitOptions)
|
||||
: getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions)
|
||||
}
|
||||
})
|
||||
|
||||
import { getWorkItem } from './get-work-item'
|
||||
import { _resetOwnerRepoCache } from '../../repositories/github-repository-identity'
|
||||
|
||||
describe('GitHub issue open-by-number origin preference', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
getOwnerRepoMock.mockReset()
|
||||
getIssueOwnerRepoMock.mockReset()
|
||||
getOwnerRepoForRemoteMock.mockReset()
|
||||
resolvePRRepositoryCandidatesMock.mockReset()
|
||||
resolveIssueSourceMock.mockReset()
|
||||
rateLimitGuardMock.mockReset()
|
||||
rateLimitGuardMock.mockReturnValue({ blocked: false })
|
||||
noteRateLimitSpendMock.mockReset()
|
||||
acquireMock.mockReset()
|
||||
releaseMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
getOwnerRepoForRemoteMock.mockImplementation(
|
||||
async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) =>
|
||||
remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null
|
||||
)
|
||||
resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => {
|
||||
const origin = await getOwnerRepoMock(repoPath, connectionId)
|
||||
const repository = origin ? { host: 'github.com', ...origin } : null
|
||||
return { candidates: repository ? [repository] : [], headRepo: repository }
|
||||
})
|
||||
_resetOwnerRepoCache()
|
||||
})
|
||||
|
||||
it('does not run a bare issue lookup when explicit origin identity is unresolved', async () => {
|
||||
resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false })
|
||||
|
||||
await expect(getWorkItem('/repo-root', 7, 'issue', null, {}, 'origin')).resolves.toBeNull()
|
||||
|
||||
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
|
||||
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the issue probe on untyped open when origin identity is unresolved', async () => {
|
||||
const origin = { owner: 'fork', repo: 'orca', host: 'github.com' }
|
||||
resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false })
|
||||
getOwnerRepoMock.mockResolvedValue(origin)
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [origin], headRepo: origin })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 7,
|
||||
title: 'Origin PR',
|
||||
state: 'open',
|
||||
labels: [],
|
||||
isDraft: false,
|
||||
url: 'https://github.com/fork/orca/pull/7',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'origin/fix',
|
||||
updatedAt: '2026-04-02T00:00:00Z',
|
||||
author: { login: 'octocat' }
|
||||
})
|
||||
})
|
||||
|
||||
const item = await getWorkItem('/repo-root', 7, undefined, null, {}, 'origin')
|
||||
|
||||
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
|
||||
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual(
|
||||
expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca'])
|
||||
)
|
||||
expect(item).toMatchObject({ number: 7, type: 'pr' })
|
||||
})
|
||||
})
|
||||
@@ -1,215 +0,0 @@
|
||||
import type * as GithubRepositoryIdentityModule from '../../repositories/github-repository-identity'
|
||||
import type * as GhExecFileModule from '../../../git/command-runner/hosted-cli/gh-exec-file'
|
||||
import type * as GithubApiRepositoryModule from '../../repositories/github-api-repository'
|
||||
import type * as GithubApiRepositoryRemoteProbeModule from '../../repositories/github-api-repository-remote-probe'
|
||||
import type * as GitExecFileModule from '../../../git/command-runner/git-exec-file'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as GitHubEnterpriseRepositoryModule from '../../repositories/github-enterprise-repository'
|
||||
|
||||
const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
const moduleMocks = await import('../client-test-mocks')
|
||||
return { clientMocks: moduleMocks.createGitHubClientMocks(), moduleMocks }
|
||||
})
|
||||
|
||||
vi.mock('../../execution/github-legacy-command', () =>
|
||||
moduleMocks.githubLegacyCommandModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
vi.mock('../../repositories/github-owner-repo-selection', () =>
|
||||
moduleMocks.githubOwnerSelectionModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
vi.mock('../../repositories/github-repository-identity', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GithubRepositoryIdentityModule>()),
|
||||
...moduleMocks.githubRepositoryIdentityModuleMock(clientMocks)
|
||||
}))
|
||||
|
||||
vi.mock('../../api/gh-error-classification', () =>
|
||||
moduleMocks.githubErrorClassificationModuleMock()
|
||||
)
|
||||
|
||||
vi.mock('../../execution/github-operation-slots', () =>
|
||||
moduleMocks.githubOperationSlotsModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
vi.mock('../../../git/command-runner/git-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitExecFileModule>()),
|
||||
gitExecFileAsync: clientMocks.gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/command-runner/hosted-cli/gh-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GhExecFileModule>()),
|
||||
...moduleMocks.githubHostedCliModuleMock(clientMocks)
|
||||
}))
|
||||
vi.mock('../../repositories/local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
vi.mock('../../repositories/github-enterprise-repository', async (importOriginal) =>
|
||||
moduleMocks.githubEnterpriseRepositoryModuleMock(
|
||||
await importOriginal<typeof GitHubEnterpriseRepositoryModule>()
|
||||
)
|
||||
)
|
||||
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(clientMocks))
|
||||
vi.mock('../../repositories/github-api-repository', async (importOriginal) =>
|
||||
moduleMocks.githubApiRepositoryModuleMock(
|
||||
clientMocks,
|
||||
await importOriginal<typeof GithubApiRepositoryModule>()
|
||||
)
|
||||
)
|
||||
|
||||
vi.mock('../../repositories/github-api-repository-remote-probe', async (importOriginal) =>
|
||||
moduleMocks.githubApiRepositoryRemoteProbeModuleMock(
|
||||
clientMocks,
|
||||
await importOriginal<typeof GithubApiRepositoryRemoteProbeModule>()
|
||||
)
|
||||
)
|
||||
|
||||
import { getPRForBranch } from './get-pr-for-branch'
|
||||
import { resetPRForBranchMocks } from '../client-test-harness'
|
||||
import type { RestPullRequest } from './pull-request-lookup-data'
|
||||
import type { GitHubRepositoryIdentity as OwnerRepo } from '../../../../shared/github/repositories/repository-identity'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
resolvePRRepositoryCandidatesMock,
|
||||
gitExecFileAsyncMock
|
||||
} = clientMocks
|
||||
|
||||
const ACME = { owner: 'acme', repo: 'widgets' }
|
||||
|
||||
function primeGit(
|
||||
trackedUpstream: string,
|
||||
remoteHeads: Record<string, string> = { origin: 'develop' }
|
||||
): void {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(upstream)')) {
|
||||
return { stdout: `refs/heads/feature/my-change\0${trackedUpstream}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) {
|
||||
const stdout = Object.entries(remoteHeads)
|
||||
.filter(([remote]) => args.includes(`refs/remotes/${remote}/HEA[D]`))
|
||||
.map(
|
||||
([remote, branch]) => `refs/remotes/${remote}/HEAD\0refs/remotes/${remote}/${branch}\n`
|
||||
)
|
||||
.join('')
|
||||
return { stdout, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === 'HEAD') {
|
||||
return { stdout: 'feature-head-oid\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`unexpected git call: ${args.join(' ')}`)
|
||||
})
|
||||
}
|
||||
|
||||
function restPR(
|
||||
headRefName = 'develop',
|
||||
headRepo: OwnerRepo = ACME,
|
||||
prRepo: OwnerRepo = ACME
|
||||
): RestPullRequest {
|
||||
return {
|
||||
number: 7,
|
||||
title: 'Release develop',
|
||||
state: 'closed',
|
||||
merged_at: null,
|
||||
html_url: `https://${prRepo.host ?? 'github.com'}/${prRepo.owner}/${prRepo.repo}/pull/7`,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
draft: false,
|
||||
mergeable: null,
|
||||
base: { ref: 'release', sha: 'release-oid' },
|
||||
head: {
|
||||
ref: headRefName,
|
||||
sha: 'old-develop-oid',
|
||||
repo: { name: headRepo.repo, owner: { login: headRepo.owner } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function primeGh(
|
||||
listsByHead: Record<string, RestPullRequest[]>,
|
||||
exactPR: RestPullRequest | RestPullRequest[] = Object.values(listsByHead).flat()[0] ?? restPR()
|
||||
): void {
|
||||
const repositorySlug = (pr: RestPullRequest): string =>
|
||||
new URL(pr.html_url ?? '').pathname.split('/').slice(1, 3).join('/')
|
||||
const repositoryHost = (pr: RestPullRequest): string => new URL(pr.html_url ?? '').host
|
||||
const exactPRs = Array.isArray(exactPR) ? exactPR : [exactPR]
|
||||
const prsByRepositoryAndNumber = new Map(
|
||||
[...Object.values(listsByHead).flat(), ...exactPRs].map((pr) => [
|
||||
`${repositoryHost(pr)}/${repositorySlug(pr)}#${pr.number}`,
|
||||
pr
|
||||
])
|
||||
)
|
||||
ghExecFileAsyncMock.mockImplementation(
|
||||
async (args: string[], options: { host?: string } = {}) => {
|
||||
const host = options.host ?? 'github.com'
|
||||
const match = args[1]?.match(/^repos\/([^/]+\/[^/]+)\/pulls\?head=([^&]+)/)
|
||||
if (args[0] === 'api' && match) {
|
||||
const list = (listsByHead[decodeURIComponent(match[2])] ?? []).filter(
|
||||
(pr) => repositoryHost(pr) === host && repositorySlug(pr) === match[1]
|
||||
)
|
||||
return { stdout: JSON.stringify(list) }
|
||||
}
|
||||
const exactRestMatch = args[1]?.match(/^repos\/([^/]+\/[^/]+)\/pulls\/(\d+)$/)
|
||||
if (args[0] === 'api' && exactRestMatch) {
|
||||
const pr = prsByRepositoryAndNumber.get(`${host}/${exactRestMatch[1]}#${exactRestMatch[2]}`)
|
||||
if (pr) {
|
||||
return { stdout: JSON.stringify(pr) }
|
||||
}
|
||||
throw new Error('HTTP 404 Not Found')
|
||||
}
|
||||
const repoIndex = args.indexOf('--repo')
|
||||
const exact = prsByRepositoryAndNumber.get(`${host}/${args[repoIndex + 1]}#${args[2]}`)
|
||||
if (args[0] === 'pr' && args[1] === 'view') {
|
||||
if (!exact) {
|
||||
throw new Error('HTTP 404 Not Found')
|
||||
}
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
number: exact.number,
|
||||
title: exact.title,
|
||||
state: exact.merged_at ? 'MERGED' : exact.state.toUpperCase(),
|
||||
url: exact.html_url,
|
||||
statusCheckRollup: [],
|
||||
updatedAt: exact.updated_at,
|
||||
isDraft: exact.draft,
|
||||
mergeable: 'UNKNOWN',
|
||||
baseRefName: exact.base?.ref,
|
||||
headRefName: exact.head?.ref,
|
||||
baseRefOid: exact.base?.sha,
|
||||
headRefOid: exact.head?.sha,
|
||||
headRepositoryOwner: exact.head?.repo?.owner,
|
||||
headRepository: exact.head?.repo ? { name: exact.head.repo.name } : null
|
||||
})
|
||||
}
|
||||
}
|
||||
throw new Error(`gh unavailable: ${args.join(' ')}`)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('issue #26948: a branch tracking the default branch', () => {
|
||||
beforeEach(() => {
|
||||
resetPRForBranchMocks(clientMocks)
|
||||
getOwnerRepoMock.mockResolvedValue(ACME)
|
||||
})
|
||||
|
||||
it('does not query another GitHub server for a known public GitHub head', async () => {
|
||||
const enterprise = { ...ACME, host: 'ghe.example' }
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
||||
candidates: [enterprise, ACME],
|
||||
headRepo: ACME
|
||||
})
|
||||
primeGit('')
|
||||
primeGh({
|
||||
'acme:feature/my-change': [
|
||||
{ ...restPR('feature/my-change', enterprise, enterprise), state: 'open' }
|
||||
]
|
||||
})
|
||||
expect(await getPRForBranch('/repo-root', 'feature/my-change')).toBeNull()
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some(([, options]) => options?.host === 'ghe.example')
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
import type * as GithubRepositoryIdentityModule from '../../repositories/github-repository-identity'
|
||||
import type * as GhExecFileModule from '../../../git/command-runner/hosted-cli/gh-exec-file'
|
||||
import type * as GithubApiRepositoryModule from '../../repositories/github-api-repository'
|
||||
import type * as GithubApiRepositoryRemoteProbeModule from '../../repositories/github-api-repository-remote-probe'
|
||||
import type * as GitExecFileModule from '../../../git/command-runner/git-exec-file'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as GitHubEnterpriseRepositoryModule from '../../repositories/github-enterprise-repository'
|
||||
|
||||
const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
const moduleMocks = await import('../client-test-mocks')
|
||||
return { clientMocks: moduleMocks.createGitHubClientMocks(), moduleMocks }
|
||||
})
|
||||
|
||||
vi.mock('../../execution/github-legacy-command', () =>
|
||||
moduleMocks.githubLegacyCommandModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
vi.mock('../../repositories/github-owner-repo-selection', () =>
|
||||
moduleMocks.githubOwnerSelectionModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
vi.mock('../../repositories/github-repository-identity', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GithubRepositoryIdentityModule>()),
|
||||
...moduleMocks.githubRepositoryIdentityModuleMock(clientMocks)
|
||||
}))
|
||||
|
||||
vi.mock('../../api/gh-error-classification', () =>
|
||||
moduleMocks.githubErrorClassificationModuleMock()
|
||||
)
|
||||
|
||||
vi.mock('../../execution/github-operation-slots', () =>
|
||||
moduleMocks.githubOperationSlotsModuleMock(clientMocks)
|
||||
)
|
||||
|
||||
vi.mock('../../../git/command-runner/git-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitExecFileModule>()),
|
||||
gitExecFileAsync: clientMocks.gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/command-runner/hosted-cli/gh-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GhExecFileModule>()),
|
||||
...moduleMocks.githubHostedCliModuleMock(clientMocks)
|
||||
}))
|
||||
vi.mock('../../repositories/local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
vi.mock('../../repositories/github-enterprise-repository', async (importOriginal) =>
|
||||
moduleMocks.githubEnterpriseRepositoryModuleMock(
|
||||
await importOriginal<typeof GitHubEnterpriseRepositoryModule>()
|
||||
)
|
||||
)
|
||||
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(clientMocks))
|
||||
vi.mock('../../repositories/github-api-repository', async (importOriginal) =>
|
||||
moduleMocks.githubApiRepositoryModuleMock(
|
||||
clientMocks,
|
||||
await importOriginal<typeof GithubApiRepositoryModule>()
|
||||
)
|
||||
)
|
||||
|
||||
vi.mock('../../repositories/github-api-repository-remote-probe', async (importOriginal) =>
|
||||
moduleMocks.githubApiRepositoryRemoteProbeModuleMock(
|
||||
clientMocks,
|
||||
await importOriginal<typeof GithubApiRepositoryRemoteProbeModule>()
|
||||
)
|
||||
)
|
||||
|
||||
import { getRestPRForBranch } from './pr-branch-lookup'
|
||||
|
||||
import { resetPRForBranchMocks } from '../client-test-harness'
|
||||
|
||||
const { ghExecFileAsyncMock } = clientMocks
|
||||
|
||||
describe('PR lookup conditional REST reads', () => {
|
||||
beforeEach(() => resetPRForBranchMocks(clientMocks))
|
||||
|
||||
it('revalidates both mixed and active discovery responses before falling back to history', async () => {
|
||||
const repo = { owner: 'acme', repo: 'widgets', host: 'github.com' }
|
||||
const old = Array.from({ length: 10 }, (_, i) => ({
|
||||
number: i + 1,
|
||||
state: 'closed',
|
||||
title: 'Old',
|
||||
head: { ref: 'feature' }
|
||||
}))
|
||||
const open = { number: 11, state: 'open', title: 'Active', head: { ref: 'feature' } }
|
||||
const http = (data: unknown, etag: string) => ({
|
||||
stdout: `HTTP/2.0 200 OK\nEtag: "${etag}"\r\n\r\n${JSON.stringify(data)}`
|
||||
})
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(http(old, 'recent'))
|
||||
.mockResolvedValueOnce(http([open], 'active'))
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('304'), { stdout: 'HTTP/2.0 304 Not Modified\n\n' })
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('304'), { stdout: 'HTTP/2.0 304 Not Modified\n\n' })
|
||||
)
|
||||
await expect(
|
||||
getRestPRForBranch(repo, 'acme', 'feature', { cwd: '/repo' })
|
||||
).resolves.toMatchObject({ number: 11 })
|
||||
await expect(
|
||||
getRestPRForBranch(repo, 'acme', 'feature', { cwd: '/repo' })
|
||||
).resolves.toMatchObject({ number: 11 })
|
||||
expect(ghExecFileAsyncMock.mock.calls[2][0]).toContain('If-None-Match: "recent"')
|
||||
expect(ghExecFileAsyncMock.mock.calls[3][0]).toContain('If-None-Match: "active"')
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import type * as GithubRepositoryIdentityModule from '../../repositories/github-repository-identity'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
|
||||
import type {
|
||||
GitHubRepoContext,
|
||||
LocalGitExecOptions
|
||||
} from '../../repositories/github-repository-identity'
|
||||
|
||||
import { __resetHostedReviewBranchCacheForTests } from '../../../source-control/hosted-review-branch-cache'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ resolve: vi.fn(), acquire: vi.fn(), release: vi.fn() }))
|
||||
vi.mock('../../execution/github-operation-slots', () => ({
|
||||
acquire: mocks.acquire,
|
||||
release: mocks.release
|
||||
}))
|
||||
|
||||
vi.mock('../../repositories/github-repository-identity', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GithubRepositoryIdentityModule>()),
|
||||
githubRepoContext: (
|
||||
repoPath: string,
|
||||
connectionId: string | null,
|
||||
options: LocalGitExecOptions
|
||||
) => ({
|
||||
repoPath,
|
||||
connectionId,
|
||||
...options
|
||||
}),
|
||||
ghRepoExecOptions: (context: GitHubRepoContext) => context
|
||||
}))
|
||||
vi.mock('./branch-lookup-resolution', () => ({
|
||||
resolvePRForBranchOutcome: mocks.resolve
|
||||
}))
|
||||
vi.mock('./pr-for-branch-outcome', async () => ({
|
||||
getPRForBranchOutcome: (await import('./pr-for-branch-outcome')).getPRForBranchOutcome
|
||||
}))
|
||||
|
||||
vi.mock('../fetch/repo-slug-upstream', async () => ({
|
||||
getRepoSlug: vi.fn(async () => ({ owner: 'acme', repo: 'orca' }))
|
||||
}))
|
||||
|
||||
vi.mock('./pr-lookup-rate-limit', async () => ({
|
||||
getGitHubPRLookupRateLimitBlock: vi.fn(async () => null)
|
||||
}))
|
||||
|
||||
vi.mock('../create/create-github-pull-request', async () => ({
|
||||
createGitHubPullRequest: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../gitlab/merge-requests/merge-request-lookup', () => ({
|
||||
getProjectSlug: vi.fn(async () => null)
|
||||
}))
|
||||
import { getPRForBranchOutcome } from './pr-for-branch-outcome'
|
||||
|
||||
const outcome: PRRefreshOutcome = { kind: 'no-pr', fetchedAt: 1 }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.resolve.mockReset()
|
||||
__resetHostedReviewBranchCacheForTests()
|
||||
mocks.acquire.mockResolvedValue(undefined)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('PR lookup coalescing', () => {
|
||||
it('cleans up errors so later reads can recover', async () => {
|
||||
mocks.resolve.mockRejectedValueOnce(new Error('network down')).mockResolvedValue(outcome)
|
||||
const results = await Promise.all([
|
||||
getPRForBranchOutcome('/repo', 'topic'),
|
||||
getPRForBranchOutcome('/repo', 'topic')
|
||||
])
|
||||
expect(results.every((result) => result.kind === 'upstream-error')).toBe(true)
|
||||
expect(mocks.resolve).toHaveBeenCalledTimes(1)
|
||||
await expect(getPRForBranchOutcome('/repo', 'topic')).resolves.toEqual(outcome)
|
||||
expect(mocks.resolve).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,209 +0,0 @@
|
||||
import type * as GithubRepositoryIdentityModule from '../repositories/github-repository-identity'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const mocks = vi.hoisted(() => ({ exec: vi.fn(), guard: vi.fn() }))
|
||||
vi.mock('../../git/command-runner/hosted-cli/gh-exec-file', () => ({
|
||||
ghExecFileAsync: mocks.exec
|
||||
}))
|
||||
|
||||
vi.mock('../repositories/github-repository-identity', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GithubRepositoryIdentityModule>()),
|
||||
ghRepoExecOptions: () => ({}),
|
||||
githubRepoContext: () => ({})
|
||||
}))
|
||||
vi.mock('../api/rate-limit', () => ({
|
||||
repositoryRateLimitGuard: mocks.guard,
|
||||
noteRepositoryRateLimitSpend: vi.fn()
|
||||
}))
|
||||
import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details'
|
||||
const repository = { owner: 'acme', repo: 'widgets' }
|
||||
|
||||
describe('explicit task refresh conversation reads', () => {
|
||||
beforeEach(() => {
|
||||
mocks.exec.mockReset()
|
||||
mocks.guard.mockReset()
|
||||
mocks.guard.mockReturnValue({ blocked: false })
|
||||
})
|
||||
it('bypasses body, comments and timeline caches and reads every comments page', async () => {
|
||||
mocks.exec.mockImplementation(async (args: string[]) => ({
|
||||
stdout: args.some((arg) => arg.includes('/comments?'))
|
||||
? JSON.stringify([
|
||||
[{ id: 1, user: null, body: 'first', created_at: '', html_url: '' }],
|
||||
[{ id: 2, user: null, body: 'second', created_at: '', html_url: '' }]
|
||||
])
|
||||
: args.some((arg) => arg.includes('/timeline?'))
|
||||
? ''
|
||||
: JSON.stringify({ body: 'current', assignees: [] })
|
||||
}))
|
||||
const result = await getIssueBodyAndComments('/repo', 42, repository, null, {}, true)
|
||||
expect(result.body).toBe('current')
|
||||
expect(result.comments.map((comment) => comment.body)).toEqual(['first', 'second'])
|
||||
for (const [args] of mocks.exec.mock.calls.filter(([args]) =>
|
||||
args.some((arg: string) => arg.startsWith('repos/'))
|
||||
)) {
|
||||
expect(args).not.toContain('--cache')
|
||||
}
|
||||
expect(
|
||||
mocks.exec.mock.calls.find(([args]) =>
|
||||
args.some((arg: string) => arg.includes('/comments?'))
|
||||
)?.[0]
|
||||
).toEqual(expect.arrayContaining(['--paginate', '--slurp']))
|
||||
})
|
||||
it('marks only the comments the reading gh account wrote', async () => {
|
||||
const comment = (id: number, login: string) => ({
|
||||
id,
|
||||
user: { login, avatar_url: '' },
|
||||
body: 'LGTM',
|
||||
created_at: '',
|
||||
html_url: ''
|
||||
})
|
||||
mocks.exec.mockImplementation(async (args: string[]) => ({
|
||||
stdout: args.includes('user')
|
||||
? 'Me\n'
|
||||
: args.some((arg) => arg.includes('/comments?'))
|
||||
? JSON.stringify([[comment(1, 'alice')], [comment(2, 'me')]])
|
||||
: args.some((arg) => arg.includes('/timeline?'))
|
||||
? ''
|
||||
: JSON.stringify({ body: 'current' })
|
||||
}))
|
||||
const result = await getIssueBodyAndComments('/repo', 42, repository, null, {}, true)
|
||||
expect(result.comments.map((c) => [c.author, c.viewerDidAuthor])).toEqual([
|
||||
['alice', undefined],
|
||||
['me', true]
|
||||
])
|
||||
})
|
||||
|
||||
it('retains ordinary fallback behavior but does not report an explicit rate-limited read as success', async () => {
|
||||
mocks.guard.mockReturnValue({ blocked: true })
|
||||
expect(await getIssueBodyAndComments('/repo', 42, repository)).toEqual({
|
||||
body: '',
|
||||
comments: [],
|
||||
assignees: [],
|
||||
timelineItems: []
|
||||
})
|
||||
await expect(getIssueBodyAndComments('/repo', 42, repository, null, {}, true)).rejects.toThrow()
|
||||
expect(mocks.exec).not.toHaveBeenCalled()
|
||||
})
|
||||
it('falls back from incomplete GraphQL conversations and malformed timelines', async () => {
|
||||
mocks.exec.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: { body: 'new', comments: { nodes: [], pageInfo: { hasNextPage: true } } }
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(
|
||||
await getIssueDetailsViaGraphQL('/repo', 42, repository, null, {}, false, true)
|
||||
).toBeNull()
|
||||
mocks.exec.mockImplementation(async (args: string[]) => ({
|
||||
stdout: args.some((arg) => arg.includes('/timeline?'))
|
||||
? 'not-json'
|
||||
: args.some((arg) => arg.includes('/comments?'))
|
||||
? '[]'
|
||||
: '{"body":"new"}'
|
||||
}))
|
||||
await expect(getIssueBodyAndComments('/repo', 42, repository, null, {}, true)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('pairs issue and comment image sources through collapsed and REST detail reads', async () => {
|
||||
mocks.exec.mockReset()
|
||||
mocks.guard.mockReturnValue({ blocked: false })
|
||||
const body =
|
||||
''
|
||||
const html =
|
||||
'<img src="https://private-user-images.githubusercontent.com/1/ce11040a-fb66-4289-927f-547b16dfc488.png?jwt=TEST">'
|
||||
mocks.exec.mockImplementation(async (args: string[]) => ({
|
||||
stdout: JSON.stringify(
|
||||
args.includes('graphql')
|
||||
? {
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body,
|
||||
bodyHTML: html,
|
||||
comments: { nodes: [{ databaseId: 1, body, bodyHTML: html }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: args.some((arg) => arg.includes('/timeline?'))
|
||||
? []
|
||||
: args.some((arg) => arg.includes('/comments?'))
|
||||
? [{ id: 1, user: null, body, body_html: html }]
|
||||
: { body, body_html: html }
|
||||
)
|
||||
}))
|
||||
const collapsed = await getIssueDetailsViaGraphQL('/repo', 42, repository)
|
||||
const rest = await getIssueBodyAndComments('/repo', 42, repository)
|
||||
for (const details of [collapsed!, rest]) {
|
||||
expect(details.body).toBe(body)
|
||||
expect(details.bodyImageUrls).toEqual({
|
||||
'https://github.com/user-attachments/assets/ce11040a-fb66-4289-927f-547b16dfc488':
|
||||
'https://private-user-images.githubusercontent.com/1/ce11040a-fb66-4289-927f-547b16dfc488.png?jwt=TEST'
|
||||
})
|
||||
expect(details.comments[0].bodyImageUrls).toEqual(details.bodyImageUrls)
|
||||
}
|
||||
for (const [args] of mocks.exec.mock.calls.filter(([args]) =>
|
||||
args.some((arg: string) => /\/issues\/42(?:$|\/comments)/.test(arg))
|
||||
)) {
|
||||
expect(args).toContain('Accept: application/vnd.github.full+json')
|
||||
expect(args).not.toContain('--cache')
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['optional HTML', 'required body', 'pathless', 'rejection without errors'] as const)(
|
||||
'admits rejected issue stdout only for available optional HTML failures (%s)',
|
||||
async (input) => {
|
||||
mocks.exec.mockReset()
|
||||
mocks.guard.mockReturnValue({ blocked: false })
|
||||
const body =
|
||||
''
|
||||
const html =
|
||||
'<img src="https://private-user-images.githubusercontent.com/1/ce11040a-fb66-4289-927f-547b16dfc488.png?jwt=TEST">'
|
||||
mocks.exec.mockRejectedValueOnce(
|
||||
Object.assign(new Error('GraphQL failed'), {
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body,
|
||||
bodyHTML: html,
|
||||
comments: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [{ databaseId: 1, body, bodyHTML: html }]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
errors:
|
||||
input === 'rejection without errors'
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
path:
|
||||
input === 'pathless'
|
||||
? undefined
|
||||
: ['repository', 'issue', input === 'required body' ? 'body' : 'bodyHTML']
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
)
|
||||
mocks.exec.mockResolvedValueOnce({ stdout: '[]' })
|
||||
const result = await getIssueDetailsViaGraphQL('/repo', 42, repository, null, {}, false, true)
|
||||
if (input !== 'optional HTML') {
|
||||
expect(result).toBeNull()
|
||||
expect(mocks.exec).toHaveBeenCalledTimes(1)
|
||||
return
|
||||
}
|
||||
expect(result?.body).toBe(body)
|
||||
expect(result?.bodyImageUrls).toBeUndefined()
|
||||
expect(result?.comments[0].bodyImageUrls).toEqual({
|
||||
'https://github.com/user-attachments/assets/ce11040a-fb66-4289-927f-547b16dfc488':
|
||||
'https://private-user-images.githubusercontent.com/1/ce11040a-fb66-4289-927f-547b16dfc488.png?jwt=TEST'
|
||||
})
|
||||
expect(mocks.exec).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { setPRAutoRefreshCadence } from '../../../shared/review-refresh-policy'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
read: vi.fn(async () => []),
|
||||
pending: vi.fn(() => [{ headSha: 'old-head', movedAt: 1, expiresAt: null }])
|
||||
}))
|
||||
vi.mock('../pull-requests/refresh/pr-refresh-coordinator', () => ({ schedulePRWatchRead: vi.fn() }))
|
||||
vi.mock('./pr-head-check-read', () => ({ readPRHeadCheckRollup: mocks.read }))
|
||||
|
||||
import { PREarlierHeadJobs } from './pr-earlier-head-jobs'
|
||||
import { prWatchPlanDueAt } from '../pull-requests/refresh/pr-refresh-watch-jobs'
|
||||
|
||||
afterEach(() => setPRAutoRefreshCadence('default'))
|
||||
|
||||
it.each([
|
||||
['default', 120_000],
|
||||
['reduced', 10 * 60_000],
|
||||
['minimal', 20 * 60_000],
|
||||
// A chat is waiting for the result, so off slows to minimal instead of stopping.
|
||||
['off', 20 * 60_000]
|
||||
] as const)(
|
||||
'an earlier head’s next read follows the %s watch cadence',
|
||||
async (cadence, interval) => {
|
||||
setPRAutoRefreshCadence(cadence)
|
||||
const jobs = new PREarlierHeadJobs({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read-only probe only requests pending heads.
|
||||
database: { read: async () => mocks.pending() } as never,
|
||||
apply: async () => false,
|
||||
logger: { warn: vi.fn() },
|
||||
now: () => 1_000
|
||||
})
|
||||
const readAt = await jobs.read(
|
||||
{ prKey: 'github.com/acme/repo#7', headSha: 'old-head' },
|
||||
{ repoId: 'r', repoPath: '/repo', branch: 'topic', repoKind: 'git', cacheKey: 'k' }
|
||||
)
|
||||
expect(readAt).toBe(1_000)
|
||||
expect(prWatchPlanDueAt({ kind: 'head', at: readAt! })).toBe(1_000 + interval)
|
||||
}
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
|
||||
import { getPRForBranchOutcome } from '../../client/lookup/pr-for-branch-outcome'
|
||||
import { runHostedPRRead } from './pr-refresh-coordinator'
|
||||
import { refreshIntervalForCandidate } from './pr-refresh-candidate-policy'
|
||||
|
||||
vi.mock('../../client/lookup/branch-lookup-resolution', () => ({
|
||||
resolvePRForBranchOutcome: async () => ({
|
||||
kind: 'found',
|
||||
fetchedAt: Date.now(),
|
||||
pr: makePR({
|
||||
headSha: 'head',
|
||||
checksStatus: 'success',
|
||||
checksReadComplete: true,
|
||||
checksFinal: true,
|
||||
autoMergeEnabled: false,
|
||||
mergeQueueRequired: false
|
||||
})
|
||||
})
|
||||
}))
|
||||
vi.mock('../../execution/github-operation-slots', () => ({
|
||||
acquire: async () => {},
|
||||
release: () => {}
|
||||
}))
|
||||
|
||||
it('stretches a confirmed quiet PR’s interval by the cadence, as it does every other interval', async () => {
|
||||
const candidate = makeCandidate({
|
||||
branch: 'branch',
|
||||
currentHeadOid: 'head',
|
||||
cachedHeadOid: 'head',
|
||||
cachedFetchedAt: 2_000,
|
||||
cachedPRState: 'open',
|
||||
cachedChecksStatus: 'success',
|
||||
cachedHasPR: true
|
||||
})
|
||||
const read = () =>
|
||||
runHostedPRRead(candidate, () =>
|
||||
getPRForBranchOutcome('/repo', 'branch', null, null, null, { currentHeadOid: 'head' })
|
||||
)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
await read()
|
||||
vi.setSystemTime(2_000)
|
||||
await read()
|
||||
const unconfirmed = { ...candidate, fallbackPRNumber: 9 }
|
||||
expect(
|
||||
(['default', 'reduced', 'minimal', 'off'] as const).map((cadence) => [
|
||||
refreshIntervalForCandidate(candidate, cadence),
|
||||
refreshIntervalForCandidate(unconfirmed, cadence)
|
||||
])
|
||||
).toEqual([
|
||||
[300_000, 120_000],
|
||||
[1_500_000, 600_000],
|
||||
[3_000_000, 1_200_000],
|
||||
[Infinity, Infinity]
|
||||
])
|
||||
vi.useRealTimers()
|
||||
})
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types'
|
||||
|
||||
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
|
||||
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
|
||||
})
|
||||
|
||||
vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
|
||||
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
|
||||
return moduleMocks.clientModuleMock(coordinatorMocks)
|
||||
})
|
||||
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
|
||||
return {
|
||||
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
|
||||
}
|
||||
})
|
||||
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
|
||||
|
||||
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
|
||||
|
||||
const { sendMock, getPRForBranchOutcomeMock } = coordinatorMocks
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('pr-refresh-coordinator', () => {
|
||||
beforeEach(() => {
|
||||
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
|
||||
})
|
||||
|
||||
// Transforming the coordinator's module graph is the slow first step on a loaded machine: do it
|
||||
// once under the hook limit, so each test still imports its own fresh instance, quickly.
|
||||
beforeAll(async () => {
|
||||
await import('./pr-refresh-coordinator')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// A body that failed must not leave its timers to run on the next test's clock.
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('preserves an active refresh queued while a visible refresh is in flight', async () => {
|
||||
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
|
||||
await import('./pr-refresh-coordinator')
|
||||
const visibleOutcome = deferred<{
|
||||
kind: 'found'
|
||||
pr: PRInfo
|
||||
fetchedAt: number
|
||||
}>()
|
||||
getPRForBranchOutcomeMock.mockReturnValueOnce(visibleOutcome.promise).mockResolvedValueOnce({
|
||||
kind: 'found',
|
||||
pr: makePR({ checksStatus: 'success', state: 'merged' }),
|
||||
fetchedAt: Date.now()
|
||||
})
|
||||
|
||||
const candidate = makeCandidate()
|
||||
reportVisiblePRRefreshCandidates([candidate], 1, 1)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
enqueuePRRefresh({ ...candidate, cachedFetchedAt: Date.now() }, 'active', 80, 1)
|
||||
visibleOutcome.resolve({
|
||||
kind: 'found',
|
||||
pr: makePR({ checksStatus: 'pending', mergeable: 'MERGEABLE' }),
|
||||
fetchedAt: Date.now()
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const inFlightEvents = sendMock.mock.calls
|
||||
.map(([, event]) => event)
|
||||
.filter((event) => event.status === 'in-flight')
|
||||
|
||||
expect(inFlightEvents.map((event) => event.reason)).toEqual(['visible', 'active'])
|
||||
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
|
||||
|
||||
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
|
||||
|
||||
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
|
||||
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
|
||||
})
|
||||
vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
|
||||
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
|
||||
return moduleMocks.clientModuleMock(coordinatorMocks)
|
||||
})
|
||||
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
|
||||
return {
|
||||
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
|
||||
}
|
||||
})
|
||||
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
|
||||
|
||||
const { getPRForBranchOutcomeMock } = coordinatorMocks
|
||||
|
||||
function found(state: 'open' | 'closed' | 'merged'): PRRefreshOutcome {
|
||||
return { kind: 'found', pr: makePR({ state, checksStatus: 'success' }), fetchedAt: Date.now() }
|
||||
}
|
||||
|
||||
describe('main refresh completion ordering', () => {
|
||||
beforeEach(() => moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks))
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('preserves a newer failure gate when an older successful lookup completes', async () => {
|
||||
let finishOlder: (outcome: PRRefreshOutcome) => void = () => {}
|
||||
getPRForBranchOutcomeMock
|
||||
.mockImplementationOnce(
|
||||
() => new Promise<PRRefreshOutcome>((resolve) => (finishOlder = resolve))
|
||||
)
|
||||
.mockImplementation(async () => ({
|
||||
kind: 'upstream-error',
|
||||
errorType: 'rate_limited',
|
||||
message: 'wait',
|
||||
fetchedAt: Date.now(),
|
||||
retryDisabledUntil: Date.now() + 300_000
|
||||
}))
|
||||
const { reportVisiblePRRefreshCandidates, refreshPRNow } =
|
||||
await import('./pr-refresh-coordinator')
|
||||
const candidate = makeCandidate({ isSelected: true })
|
||||
reportVisiblePRRefreshCandidates([candidate], 1, 1)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await refreshPRNow(candidate)
|
||||
finishOlder(found('open'))
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
|
||||
await expect(refreshPRNow(candidate)).resolves.toMatchObject({ errorType: 'rate_limited' })
|
||||
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
|
||||
|
||||
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
|
||||
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
|
||||
})
|
||||
|
||||
vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
|
||||
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
|
||||
return moduleMocks.clientModuleMock(coordinatorMocks)
|
||||
})
|
||||
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
|
||||
return {
|
||||
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
|
||||
}
|
||||
})
|
||||
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
|
||||
|
||||
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
|
||||
|
||||
const { getPRForBranchOutcomeMock } = coordinatorMocks
|
||||
const LINKED_PR_KEY = 'local::runtime:host::/repo::pr::42'
|
||||
|
||||
describe('pr-refresh queue growth bounds', () => {
|
||||
beforeEach(() => {
|
||||
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
|
||||
getPRForBranchOutcomeMock.mockImplementation(async () => ({
|
||||
kind: 'found' as const,
|
||||
pr: makePR({ checksStatus: 'pending' as const }),
|
||||
fetchedAt: Date.now()
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps the newer alias when a branch switch lands mid-request', async () => {
|
||||
const { reportVisiblePRRefreshCandidates, _getPRRefreshAliasCountForTests } =
|
||||
await import('./pr-refresh-coordinator')
|
||||
|
||||
const churnCandidate = (index: number): GitHubPRRefreshCandidate =>
|
||||
makeCandidate({
|
||||
cacheKey: `/repo::churn/${index}`,
|
||||
branch: `churn/${index}`,
|
||||
worktreeId: 'wt-churn',
|
||||
linkedPRNumber: 42,
|
||||
cachedFetchedAt: null
|
||||
})
|
||||
|
||||
let switched = false
|
||||
getPRForBranchOutcomeMock.mockImplementation(async () => {
|
||||
if (!switched) {
|
||||
switched = true
|
||||
// Re-enqueue after dequeue so the older in-flight snapshot merges into a newer queued entry.
|
||||
reportVisiblePRRefreshCandidates([churnCandidate(2)], 2, 1)
|
||||
}
|
||||
return {
|
||||
kind: 'found' as const,
|
||||
pr: makePR({ checksStatus: 'pending' as const }),
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
reportVisiblePRRefreshCandidates([churnCandidate(1)], 1, 1)
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
|
||||
expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(1)
|
||||
const broadcasts = coordinatorMocks.sendMock.mock.calls.filter(
|
||||
(call) => call[0] === 'gh:prRefreshEvent'
|
||||
) as [string, { aliases: { cacheKey: string }[] }][]
|
||||
const lastBroadcast = broadcasts.at(-1)
|
||||
expect(lastBroadcast?.[1].aliases.map((alias) => alias.cacheKey)).toEqual(['/repo::churn/2'])
|
||||
})
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
|
||||
import type { PullRequestLookupData } from '../../client/lookup/pull-request-lookup-data'
|
||||
import { makeCandidate } from './pr-refresh-coordinator-test-harness'
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
lookup: vi.fn(),
|
||||
conflict: vi.fn(),
|
||||
stack: { number: 2, position: 1, size: 2, baseRefName: 'main', baseSha: 'base-sha' }
|
||||
}))
|
||||
vi.mock('../../repositories/github-api-repository', () => ({
|
||||
resolveGitHubApiRepositoryCandidates: async () => ({
|
||||
candidates: [{ owner: 'acme', repo: 'widgets' }],
|
||||
headRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../../repositories/github-api-repository-remote-probe', () => ({
|
||||
getOriginGitHubApiRepository: async () => ({ owner: 'acme', repo: 'widgets' })
|
||||
}))
|
||||
vi.mock('../../api/rate-limit', () => ({
|
||||
repositoryRateLimitGuard: () => ({ blocked: false }),
|
||||
spendsSharedGitHubComQuota: () => false
|
||||
}))
|
||||
vi.mock('../../client/lookup/pr-number-lookup', () => ({ lookupPRByNumber: fixture.lookup }))
|
||||
vi.mock('../../client/lookup/pr-stack-summary-cache', () => ({
|
||||
getCachedGitHubPRStackSummary: async () => fixture.stack
|
||||
}))
|
||||
vi.mock('../stacks/github-pr-stack', () => ({
|
||||
hydrateGitHubPRStack: async () => fixture.stack
|
||||
}))
|
||||
vi.mock('../../client/detect/repository-merge-metadata', () => ({
|
||||
detectRepositoryMergeMetadata: async () => ({ mergeQueueRequired: true })
|
||||
}))
|
||||
vi.mock('../conflicts/conflict-summary', () => ({
|
||||
getPRConflictSummary: fixture.conflict
|
||||
}))
|
||||
|
||||
const data: PullRequestLookupData = {
|
||||
number: 12,
|
||||
title: 'Conflicting change',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/acme/widgets/pull/12',
|
||||
updatedAt: '2026-11-11T00:00:00Z',
|
||||
mergeable: 'CONFLICTING',
|
||||
baseRefName: 'main',
|
||||
baseRefOid: 'base-sha',
|
||||
headRefName: 'topic',
|
||||
headRefOid: 'head-sha',
|
||||
checksUnavailable: false,
|
||||
statusCheckRollup: [{ __typename: 'CheckRun', name: 'build', status: 'IN_PROGRESS' }]
|
||||
}
|
||||
const summary = { baseRef: 'main', baseCommit: 'base-sh', commitsBehind: 1, files: ['a.ts'] }
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('watches preserve provider checks while full and phone reads retain derived details', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
fixture.lookup.mockImplementation(async () => ({
|
||||
data: { ...data },
|
||||
dataRepo: { owner: 'acme', repo: 'widgets' }
|
||||
}))
|
||||
fixture.conflict.mockResolvedValue(summary)
|
||||
const { addPRRefreshOutcomeObserver, schedulePRWatchRead } =
|
||||
await import('./pr-refresh-coordinator')
|
||||
const { getPRForBranchOutcome, readRecentPhonePROutcome } =
|
||||
await import('../../client/lookup/pr-for-branch-outcome')
|
||||
const candidate = makeCandidate({ branch: 'topic', linkedPRNumber: 12 })
|
||||
const watched = Promise.withResolvers<PRRefreshOutcome>()
|
||||
const unsubscribe = addPRRefreshOutcomeObserver((_, outcome) => watched.resolve(outcome))
|
||||
schedulePRWatchRead({ prKey: 'github.com/acme/widgets#12' }, candidate, Date.now())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const watch = await watched.promise
|
||||
unsubscribe()
|
||||
expect(watch).toMatchObject({
|
||||
kind: 'found',
|
||||
pr: { number: 12, headSha: 'head-sha', state: 'open', checksStatus: 'pending' }
|
||||
})
|
||||
if (watch.kind !== 'found') {
|
||||
throw new Error('watch did not find the PR')
|
||||
}
|
||||
expect(watch.pr.conflictSummary).toBeUndefined()
|
||||
expect(watch.pr.stack).toBeUndefined()
|
||||
expect(watch.pr.mergeQueueRequired).toBeUndefined()
|
||||
|
||||
// An uncapped partial read must also stay separate from a concurrent full read.
|
||||
const provider = Promise.withResolvers<void>()
|
||||
const entered = Promise.withResolvers<void>()
|
||||
const conflict = Promise.withResolvers<typeof summary>()
|
||||
fixture.lookup.mockImplementation(async () => {
|
||||
entered.resolve()
|
||||
await provider.promise
|
||||
return { data: { ...data }, dataRepo: { owner: 'acme', repo: 'widgets' } }
|
||||
})
|
||||
fixture.conflict.mockReturnValue(conflict.promise)
|
||||
const partial = getPRForBranchOutcome('/repo', 'topic', 12, null, null, {
|
||||
includeDerivedData: false
|
||||
})
|
||||
await entered.promise
|
||||
const full = getPRForBranchOutcome('/repo', 'topic', 12, null, null)
|
||||
provider.resolve()
|
||||
try {
|
||||
expect(await partial).toMatchObject({ kind: 'found', pr: { conflictSummary: undefined } })
|
||||
expect(
|
||||
await readRecentPhonePROutcome('/repo', 'topic', 12, null, null, {
|
||||
currentHeadOid: 'head-sha'
|
||||
})
|
||||
).toBeNull()
|
||||
} finally {
|
||||
conflict.resolve(summary)
|
||||
}
|
||||
const complete = await full
|
||||
expect(complete).toMatchObject({
|
||||
kind: 'found',
|
||||
pr: {
|
||||
number: 12,
|
||||
headSha: 'head-sha',
|
||||
checksStatus: 'pending',
|
||||
conflictSummary: { files: ['a.ts'], commitsBehind: 1 },
|
||||
stack: { number: 2, baseRefName: 'main' },
|
||||
mergeQueueRequired: true
|
||||
}
|
||||
})
|
||||
expect(
|
||||
await readRecentPhonePROutcome('/repo', 'topic', 12, null, null, { currentHeadOid: 'head-sha' })
|
||||
).toEqual(complete)
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { ghExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
rateLimitGuardMock: vi.fn(() => ({ blocked: false })),
|
||||
noteRateLimitSpendMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/command-runner/hosted-cli/gh-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
ghExecFileAsync: ghExecFileAsyncMock
|
||||
}))
|
||||
vi.mock('../../api/rate-limit', () => ({
|
||||
repositoryRateLimitGuard: rateLimitGuardMock,
|
||||
noteRepositoryRateLimitSpend: noteRateLimitSpendMock
|
||||
}))
|
||||
import { _resetGitHubPRStackCacheForTests } from './github-pr-stack'
|
||||
import { mergeGitHubPRStack } from './github-pr-stack-async-merge'
|
||||
|
||||
const repository = { owner: 'stablyai', repo: 'orca', host: 'github.com' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
rateLimitGuardMock.mockReset()
|
||||
rateLimitGuardMock.mockReturnValue({ blocked: false })
|
||||
noteRateLimitSpendMock.mockReset()
|
||||
_resetGitHubPRStackCacheForTests()
|
||||
})
|
||||
|
||||
describe('mergeGitHubPRStack', () => {
|
||||
it('reports an in-progress merge after polling is exhausted', async () => {
|
||||
vi.useFakeTimers()
|
||||
ghExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } })
|
||||
})
|
||||
|
||||
const result = mergeGitHubPRStack({
|
||||
repository,
|
||||
prNumber: 202,
|
||||
method: 'squash',
|
||||
mergeAction: 'direct_merge',
|
||||
ghOptions: { cwd: '/repo' }
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(180_000)
|
||||
|
||||
await expect(result).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: 'GitHub is still merging this stack. Refresh to check its status.'
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(181)
|
||||
})
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GitExecFileModule from '../../git/command-runner/git-exec-file'
|
||||
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn<(args: string[]) => Promise<{ stdout: string; stderr: string }>>()
|
||||
}))
|
||||
|
||||
vi.mock('../../git/command-runner/git-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitExecFileModule>()),
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
import { _resetOwnerRepoCache } from './github-repository-identity'
|
||||
import { _resetRemoteNameListingCache } from '../../git/remotes/remote-name-listing'
|
||||
import {
|
||||
_resetOriginGitHubApiRepositoryCache,
|
||||
getGitHubApiRepositoryForRemote
|
||||
} from './github-api-repository-remote-probe'
|
||||
|
||||
// Why: no real .git config means no config signature, so caches use their short unsigned TTLs.
|
||||
const REPO_PATH = '/does-not-exist/orca-remote-probe-fixture'
|
||||
|
||||
function mockRemotes(remotes: Record<string, string>): void {
|
||||
gitExecFileAsyncMock.mockImplementation((args) => {
|
||||
if (args[0] === 'remote' && args.length === 1) {
|
||||
return Promise.resolve({ stdout: Object.keys(remotes).join('\n'), stderr: '' })
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
const url = remotes[args[2]]
|
||||
if (url) {
|
||||
return Promise.resolve({ stdout: `${url}\n`, stderr: '' })
|
||||
}
|
||||
return Promise.reject(
|
||||
Object.assign(new Error(`git remote get-url ${args[2]}`), {
|
||||
stderr: `error: No such remote '${args[2]}'`
|
||||
})
|
||||
)
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected git exec: ${JSON.stringify(args)}`))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
_resetOwnerRepoCache()
|
||||
_resetRemoteNameListingCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('getGitHubApiRepositoryForRemote', () => {
|
||||
it('still resolves a configured upstream remote', async () => {
|
||||
mockRemotes({
|
||||
origin: 'https://github.com/acme/orca.git',
|
||||
upstream: 'https://github.com/stablyai/orca.git'
|
||||
})
|
||||
|
||||
await expect(getGitHubApiRepositoryForRemote(REPO_PATH, 'upstream')).resolves.toEqual({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
host: 'github.com'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GitRunner from '../../git/command-runner/git-exec-file'
|
||||
|
||||
const { gitExecFileAsyncMock, readLocalGitConfigSignatureMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
readLocalGitConfigSignatureMock: vi.fn<() => Promise<string | undefined>>(async () => 'sig-1')
|
||||
}))
|
||||
|
||||
vi.mock('../../git/command-runner/git-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitRunner>()),
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('./local-git-config-signature', () => ({
|
||||
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
|
||||
}))
|
||||
|
||||
import { getOwnerRepoForRemote, _resetOwnerRepoCache } from './github-repository-identity'
|
||||
|
||||
const REPO = '/tmp/signed-cache-repo'
|
||||
const THIRTY_SECONDS = 30_000
|
||||
|
||||
let remoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
|
||||
const remoteGetUrlCalls = (): number =>
|
||||
gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[])[1] === 'get-url').length
|
||||
|
||||
beforeEach(() => {
|
||||
_resetOwnerRepoCache()
|
||||
vi.useRealTimers()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
remoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
readLocalGitConfigSignatureMock.mockReset()
|
||||
readLocalGitConfigSignatureMock.mockImplementation(async () => 'sig-1')
|
||||
gitExecFileAsyncMock.mockImplementation(async () => ({ stdout: remoteUrl }))
|
||||
})
|
||||
|
||||
describe('owner/repo identity cache', () => {
|
||||
it('re-probes as soon as the git config signature changes', async () => {
|
||||
vi.useFakeTimers()
|
||||
await getOwnerRepoForRemote(REPO, 'origin')
|
||||
expect(remoteGetUrlCalls()).toBe(1)
|
||||
|
||||
remoteUrl = 'https://github.com/other-org/orca.git'
|
||||
readLocalGitConfigSignatureMock.mockImplementation(async () => 'sig-2')
|
||||
vi.setSystemTime(Date.now() + THIRTY_SECONDS)
|
||||
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
|
||||
owner: 'other-org',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(remoteGetUrlCalls()).toBe(2)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -1,218 +0,0 @@
|
||||
import type * as GithubRepositoryIdentityModule from '../repositories/github-repository-identity'
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RateLimitGuardResult =
|
||||
| { blocked: false }
|
||||
| { blocked: true; remaining: number; limit: number; resetAt: number }
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getEnterpriseGitHubRepoSlugMock,
|
||||
getOwnerRepoMock,
|
||||
getWorkItemMock,
|
||||
getPRChecksMock,
|
||||
getPRCommentsMock,
|
||||
noteRepositoryRateLimitSpendMock,
|
||||
repositoryRateLimitGuardMock
|
||||
} = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getEnterpriseGitHubRepoSlugMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
getWorkItemMock: vi.fn(),
|
||||
getPRChecksMock: vi.fn(),
|
||||
getPRCommentsMock: vi.fn(),
|
||||
noteRepositoryRateLimitSpendMock:
|
||||
vi.fn<
|
||||
(
|
||||
repository: { host?: string } | null | undefined,
|
||||
bucket: string,
|
||||
cost?: number,
|
||||
options?: { cwd?: string; host?: string }
|
||||
) => void
|
||||
>(),
|
||||
repositoryRateLimitGuardMock: vi.fn<
|
||||
(
|
||||
repository: { host?: string } | null | undefined,
|
||||
bucket: string,
|
||||
options?: { cwd?: string; host?: string }
|
||||
) => RateLimitGuardResult
|
||||
>(() => ({ blocked: false }))
|
||||
}))
|
||||
|
||||
vi.mock('../../git/command-runner/hosted-cli/gh-exec-file', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../repositories/github-repository-identity', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GithubRepositoryIdentityModule>()),
|
||||
getOwnerRepoForRemote: getOwnerRepoMock,
|
||||
ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })),
|
||||
githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null,
|
||||
...localGitOptions
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('../repositories/github-owner-repo-selection', () => ({
|
||||
getOwnerRepo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../execution/github-operation-slots', () => ({
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../client/fetch/get-work-item', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../client/check/get-pr-checks', async () => ({
|
||||
getPRChecks: getPRChecksMock
|
||||
}))
|
||||
|
||||
vi.mock('../client/fetch/get-pr-comments', async () => ({
|
||||
getPRComments: getPRCommentsMock
|
||||
}))
|
||||
|
||||
vi.mock('../repositories/github-enterprise-repository', () => ({
|
||||
getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock,
|
||||
isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
vi.mock('../api/rate-limit', () => ({
|
||||
rateLimitGuard: vi.fn(() => ({ blocked: false })),
|
||||
noteRateLimitSpend: vi.fn(),
|
||||
repositoryRateLimitGuard: repositoryRateLimitGuardMock,
|
||||
noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock
|
||||
}))
|
||||
import { getWorkItemDetails } from './work-item-details'
|
||||
|
||||
import { _resetOriginGitHubApiRepositoryCache } from '../repositories/github-api-repository-remote-probe'
|
||||
|
||||
beforeEach(() => {
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
})
|
||||
|
||||
function pullRequestItem(number: number, title: string): Record<string, unknown> {
|
||||
return {
|
||||
id: `pr:${number}`,
|
||||
type: 'pr',
|
||||
number,
|
||||
title,
|
||||
state: 'open',
|
||||
url: `https://github.com/acme/widgets/pull/${number}`,
|
||||
labels: [],
|
||||
updatedAt: '2026-07-16T00:00:00Z',
|
||||
author: 'pr-author'
|
||||
}
|
||||
}
|
||||
|
||||
function auxiliaryPRResponse(args: string[]): { stdout: string } {
|
||||
const query = args.find((arg) => arg.startsWith('query=')) ?? ''
|
||||
if (query.includes('viewerViewedState')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
viewedPullRequest: {
|
||||
id: 'PR_file_list',
|
||||
files: { pageInfo: { hasNextPage: false }, nodes: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (query.includes('participants(first: 100)')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
data: { repository: { pullRequest: { participants: { nodes: [] } } } }
|
||||
})
|
||||
}
|
||||
}
|
||||
return { stdout: JSON.stringify({ data: {} }) }
|
||||
}
|
||||
|
||||
describe('getWorkItemDetails PR file listing', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
getOwnerRepoMock.mockReset()
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
getEnterpriseGitHubRepoSlugMock.mockReset()
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null)
|
||||
getWorkItemMock.mockReset()
|
||||
getPRChecksMock.mockReset()
|
||||
getPRChecksMock.mockResolvedValue([])
|
||||
getPRCommentsMock.mockReset()
|
||||
getPRCommentsMock.mockResolvedValue([])
|
||||
noteRepositoryRateLimitSpendMock.mockReset()
|
||||
repositoryRateLimitGuardMock.mockReset()
|
||||
repositoryRateLimitGuardMock.mockReturnValue({ blocked: false })
|
||||
})
|
||||
|
||||
it('loads files beyond the first 100-result REST page', async () => {
|
||||
getWorkItemMock.mockResolvedValueOnce(pullRequestItem(108, 'Large PR'))
|
||||
const restFile = (index: number) => ({
|
||||
filename: `src/file-${index}.ts`,
|
||||
status: 'modified',
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
changes: 1,
|
||||
patch: '@@ -1 +1 @@'
|
||||
})
|
||||
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? ''
|
||||
if (endpoint === 'repos/acme/widgets/pulls/108') {
|
||||
return { stdout: JSON.stringify({ head: { sha: 'head' }, base: { sha: 'base' } }) }
|
||||
}
|
||||
if (endpoint === 'repos/acme/widgets/pulls/108/files?per_page=100') {
|
||||
return {
|
||||
stdout: JSON.stringify(Array.from({ length: 100 }, (_, index) => restFile(index)))
|
||||
}
|
||||
}
|
||||
if (endpoint === 'repos/acme/widgets/pulls/108/files?per_page=100&page=2') {
|
||||
return { stdout: JSON.stringify([restFile(100)]) }
|
||||
}
|
||||
return auxiliaryPRResponse(args)
|
||||
})
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 108, 'pr')
|
||||
|
||||
expect(details?.files).toHaveLength(101)
|
||||
expect(details?.files?.at(-1)?.path).toBe('src/file-100.ts')
|
||||
const fileEndpoints = ghExecFileAsyncMock.mock.calls
|
||||
.map(([args]) => (args as string[]).find((arg) => arg.includes('/files?')))
|
||||
.filter(Boolean)
|
||||
expect(fileEndpoints).toEqual([
|
||||
'repos/acme/widgets/pulls/108/files?per_page=100',
|
||||
'repos/acme/widgets/pulls/108/files?per_page=100&page=2'
|
||||
])
|
||||
expect(
|
||||
noteRepositoryRateLimitSpendMock.mock.calls.filter(([, bucket]) => bucket === 'core')
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
repositoryRateLimitGuardMock.mock.calls.filter(([, bucket]) => bucket === 'core')
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('does not run bare PR detail commands when local host resolution fails', async () => {
|
||||
getWorkItemMock.mockResolvedValueOnce({
|
||||
...pullRequestItem(9, 'Unresolved PR'),
|
||||
prRepo: { owner: 'team', repo: 'orca' }
|
||||
})
|
||||
getOwnerRepoMock.mockResolvedValue(null)
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 9, 'pr')
|
||||
|
||||
expect(details).toMatchObject({ body: '', comments: [], checks: [], filesUnavailable: true })
|
||||
expect(getPRCommentsMock).not.toHaveBeenCalled()
|
||||
expect(getPRChecksMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,212 +0,0 @@
|
||||
import type * as GithubRepositoryIdentityModule from '../repositories/github-repository-identity'
|
||||
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RateLimitGuardResult =
|
||||
| { blocked: false }
|
||||
| { blocked: true; remaining: number; limit: number; resetAt: number }
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
getOwnerRepoForRemoteMock,
|
||||
getEnterpriseGitHubRepoSlugMock,
|
||||
getEnterpriseGitHubRepoSlugForRemoteMock,
|
||||
getWorkItemMock,
|
||||
getWorkItemByOwnerRepoMock,
|
||||
getPRChecksMock,
|
||||
getPRCommentsMock,
|
||||
repositoryRateLimitGuardMock,
|
||||
noteRepositoryRateLimitSpendMock,
|
||||
ghRepoExecOptionsMock,
|
||||
githubRepoContextMock,
|
||||
acquireMock,
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
getOwnerRepoForRemoteMock: vi.fn(),
|
||||
getEnterpriseGitHubRepoSlugMock: vi.fn(),
|
||||
getEnterpriseGitHubRepoSlugForRemoteMock: vi.fn(),
|
||||
getWorkItemMock: vi.fn(),
|
||||
getWorkItemByOwnerRepoMock: vi.fn(),
|
||||
getPRChecksMock: vi.fn(),
|
||||
getPRCommentsMock: vi.fn(),
|
||||
repositoryRateLimitGuardMock: vi.fn<
|
||||
(
|
||||
repository: { host?: string } | null | undefined,
|
||||
bucket: 'core' | 'graphql' | 'search',
|
||||
options?: { cwd?: string; wslDistro?: string }
|
||||
) => RateLimitGuardResult
|
||||
>(() => ({ blocked: false })),
|
||||
noteRepositoryRateLimitSpendMock: vi.fn(),
|
||||
ghRepoExecOptionsMock: vi.fn((context) =>
|
||||
context.connectionId
|
||||
? {}
|
||||
: { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) }
|
||||
),
|
||||
githubRepoContextMock: vi.fn((repoPath, connectionId, localGitOptions) => ({
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null,
|
||||
...localGitOptions
|
||||
})),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../git/command-runner/hosted-cli/gh-exec-file', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../repositories/github-owner-repo-selection', () => ({
|
||||
getOwnerRepo: getOwnerRepoMock
|
||||
}))
|
||||
|
||||
vi.mock('../repositories/github-repository-identity', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GithubRepositoryIdentityModule>()),
|
||||
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
|
||||
ghRepoExecOptions: ghRepoExecOptionsMock,
|
||||
githubRepoContext: githubRepoContextMock
|
||||
}))
|
||||
|
||||
vi.mock('../execution/github-operation-slots', () => ({
|
||||
acquire: acquireMock,
|
||||
release: releaseMock
|
||||
}))
|
||||
|
||||
vi.mock('../client/fetch/get-work-item', async () => ({
|
||||
getWorkItem: getWorkItemMock,
|
||||
getWorkItemWithRepository: (
|
||||
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
|
||||
).makeWorkItemDetailsLookupMock(getWorkItemMock),
|
||||
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock
|
||||
}))
|
||||
|
||||
vi.mock('../client/check/get-pr-checks', async () => ({
|
||||
getPRChecks: getPRChecksMock
|
||||
}))
|
||||
|
||||
vi.mock('../client/fetch/get-pr-comments', async () => ({
|
||||
getPRComments: getPRCommentsMock
|
||||
}))
|
||||
|
||||
vi.mock('../repositories/github-enterprise-repository', () => ({
|
||||
getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock,
|
||||
getEnterpriseGitHubRepoSlugForRemote: getEnterpriseGitHubRepoSlugForRemoteMock,
|
||||
isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
vi.mock('../api/rate-limit', () => ({
|
||||
repositoryRateLimitGuard: repositoryRateLimitGuardMock,
|
||||
noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock
|
||||
}))
|
||||
|
||||
import { getWorkItemDetails } from './work-item-details'
|
||||
|
||||
import { _resetOriginGitHubApiRepositoryCache } from '../repositories/github-api-repository-remote-probe'
|
||||
|
||||
beforeEach(() => {
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
})
|
||||
|
||||
describe('getWorkItemDetails', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
getOwnerRepoMock.mockReset()
|
||||
getOwnerRepoForRemoteMock.mockReset()
|
||||
getEnterpriseGitHubRepoSlugMock.mockReset()
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null)
|
||||
getEnterpriseGitHubRepoSlugForRemoteMock.mockReset()
|
||||
getEnterpriseGitHubRepoSlugForRemoteMock.mockResolvedValue(null)
|
||||
getWorkItemMock.mockReset()
|
||||
getWorkItemByOwnerRepoMock.mockReset()
|
||||
getPRChecksMock.mockReset()
|
||||
getPRCommentsMock.mockReset()
|
||||
repositoryRateLimitGuardMock.mockReset()
|
||||
repositoryRateLimitGuardMock.mockReturnValue({ blocked: false })
|
||||
noteRepositoryRateLimitSpendMock.mockReset()
|
||||
ghRepoExecOptionsMock.mockClear()
|
||||
githubRepoContextMock.mockClear()
|
||||
acquireMock.mockReset()
|
||||
releaseMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('caps issue timeline pagination by supported activity items', async () => {
|
||||
getWorkItemMock.mockResolvedValueOnce({
|
||||
id: 'issue:923',
|
||||
type: 'issue',
|
||||
number: 923,
|
||||
title: 'Use upstream issues',
|
||||
state: 'open',
|
||||
url: 'https://github.com/acme/widgets/issues/923',
|
||||
labels: [],
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
author: 'issue-author'
|
||||
})
|
||||
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
const makeTimelineEvent = (page: number, index: number, event: string): string =>
|
||||
JSON.stringify({
|
||||
id: `${page}:${index}`,
|
||||
event,
|
||||
actor: { login: 'issue-author', avatar_url: 'https://x/y' },
|
||||
assignee: { login: `assignee-${page}-${index}` },
|
||||
created_at: '2026-04-01T00:00:00Z'
|
||||
})
|
||||
const makeTimelinePage = (page: number, supportedCount: number): string =>
|
||||
Array.from({ length: 100 }, (_, index) =>
|
||||
makeTimelineEvent(page, index, index < supportedCount ? 'assigned' : 'subscribed')
|
||||
).join('\n')
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body: 'Issue body',
|
||||
assignees: { nodes: [] },
|
||||
participants: { nodes: [] },
|
||||
comments: { nodes: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(1, 0) })
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(2, 0) })
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(3, 10) })
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(4, 100) })
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(5, 100) })
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(6, 100) })
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(7)
|
||||
expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain(
|
||||
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1'
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[2][0]).toContain(
|
||||
'repos/acme/widgets/issues/923/timeline?per_page=100&page=2'
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[3][0]).toContain(
|
||||
'repos/acme/widgets/issues/923/timeline?per_page=100&page=3'
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[6][0]).toContain(
|
||||
'repos/acme/widgets/issues/923/timeline?per_page=100&page=6'
|
||||
)
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some((call) =>
|
||||
call[0].includes('repos/acme/widgets/issues/923/timeline?per_page=100&page=7')
|
||||
)
|
||||
).toBe(false)
|
||||
const timelineItems = details?.timelineItems
|
||||
if (!timelineItems) {
|
||||
throw new Error('Expected timeline items to be present')
|
||||
}
|
||||
expect(timelineItems).toHaveLength(300)
|
||||
expect(timelineItems.at(0)).toMatchObject({ assignee: 'assignee-3-0' })
|
||||
expect(timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' })
|
||||
})
|
||||
|
||||
// Number-based detail lookups must retain the list's source, avoiding same-number fork/upstream collisions.
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { glabExecFileAsyncMock } = vi.hoisted(() => ({ glabExecFileAsyncMock: vi.fn() }))
|
||||
|
||||
vi.mock('../../git/command-runner/hosted-cli/glab-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
glabExecFileAsync: glabExecFileAsyncMock
|
||||
}))
|
||||
import {
|
||||
_resetKnownHostsCache,
|
||||
getGlabKnownHosts,
|
||||
rememberGlabKnownHost
|
||||
} from './gitlab-known-host-probe'
|
||||
|
||||
describe('getGlabKnownHosts', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
beforeEach(() => {
|
||||
glabExecFileAsyncMock.mockReset()
|
||||
_resetKnownHostsCache()
|
||||
})
|
||||
|
||||
it('preserves a native auth refresh while an older native probe is in flight', async () => {
|
||||
let resolveProbe!: (value: { stdout: string; stderr: string }) => void
|
||||
glabExecFileAsyncMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const staleProbe = getGlabKnownHosts()
|
||||
rememberGlabKnownHost('gitlab.refreshed.test')
|
||||
resolveProbe({ stdout: 'Logged in to gitlab.com as user\n', stderr: '' })
|
||||
|
||||
await expect(staleProbe).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
type RebaseHandler = (
|
||||
event: unknown,
|
||||
args: { worktreePath: string; baseRef: string }
|
||||
) => Promise<void>
|
||||
|
||||
const { handlers, capture } = vi.hoisted(() => ({
|
||||
handlers: new Map<string, RebaseHandler>(),
|
||||
capture: vi.fn()
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: (channel: string, handler: RebaseHandler) => handlers.set(channel, handler) }
|
||||
}))
|
||||
vi.mock('../../../git/command-runner/process-capture/exec-file-capture', () => ({
|
||||
execFileCapture: capture,
|
||||
execFileCaptureToTermination: capture
|
||||
}))
|
||||
vi.mock('../../../../shared/git/fetch/git-fetch-head-lock', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
runWithGitFetchHeadLock: (_cwd: string, _signal: AbortSignal | undefined, run: () => unknown) =>
|
||||
run()
|
||||
}))
|
||||
vi.mock('../../../../shared/git/worktrees/git-worktree-operation-lock', () => ({
|
||||
runWithGitWorktreeOperationLock: (
|
||||
_cwd: string,
|
||||
_signal: AbortSignal | undefined,
|
||||
run: () => unknown
|
||||
) => run()
|
||||
}))
|
||||
|
||||
import { registerGitRemoteBranchMutationHandlers } from './branch-mutation-handlers'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('surfaces the fetch diagnostic and retry guidance through the rebase IPC handler', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubEnv('GIT_SSH_COMMAND', 'ssh')
|
||||
const original = Object.assign(
|
||||
new Error('error: fetching ref refs/remotes/origin/main failed: incorrect old value provided'),
|
||||
{
|
||||
code: 1,
|
||||
stderr: 'error: fetching ref refs/remotes/origin/main failed: incorrect old value provided'
|
||||
}
|
||||
)
|
||||
capture.mockImplementation((_command, args, options) => {
|
||||
options.onChildTerminated()
|
||||
if (args[0] === 'fetch') {
|
||||
return Promise.reject(original)
|
||||
}
|
||||
return Promise.resolve({ stdout: args[0] === 'remote' ? 'origin\n' : '', stderr: '' })
|
||||
})
|
||||
const store = {
|
||||
getRepos: () => [{ id: 'repo', path: '/repo', displayName: 'repo', badgeColor: '', addedAt: 0 }]
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This handler only reads getRepos and checks optional runtime accessors.
|
||||
registerGitRemoteBranchMutationHandlers({ store } as never)
|
||||
const result = handlers.get('git:rebaseFromBase')!(null, {
|
||||
worktreePath: '/repo',
|
||||
baseRef: 'origin/main'
|
||||
})
|
||||
const rejection = expect(result).rejects.toMatchObject({
|
||||
message: expect.stringMatching(
|
||||
/fetching ref refs\/remotes\/origin\/main failed: incorrect old value provided[\s\S]+Another Git process updated remote references\. Try again\./
|
||||
),
|
||||
cause: expect.objectContaining({ cause: original })
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
await rejection
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user