mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
test: remove 242 low-value tests (#39487)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -2797,24 +2797,15 @@
|
||||
"src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts": 52,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/agent-session-conversation-outline.test.ts": 375,
|
||||
"src/main/native-chat/agent-session-wire/observations/testing/agent-session-current-requests.test.ts": 271,
|
||||
"src/main/native-chat/agent-session-wire/publication/subscribers/testing/agent-session-delta-coalescer-overflow-prefix.test.ts": 105,
|
||||
"src/main/native-chat/agent-session-wire/publication/subscribers/testing/agent-session-delta-coalescer-protected.test.ts": 68,
|
||||
"src/main/native-chat/agent-session-wire/publication/subscribers/testing/agent-session-delta-coalescer.test.ts": 61,
|
||||
"src/main/native-chat/agent-session-wire/publication/subscribers/testing/agent-session-empty-delta-retention.test.ts": 362,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/agent-session-history-batches.test.ts": 837,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/agent-session-history-byte-accounting.test.ts": 1200,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/agent-session-history-byte-limits.test.ts": 858,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/agent-session-history-conversation-window.test.ts": 3502,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/agent-session-history-forward-read-budget.test.ts": 2439,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/agent-session-history-large-identities.test.ts": 1124,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/agent-session-history-page-scaling.test.ts": 57,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/agent-session-history-page.test.ts": 1049,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/agent-session-item-wire-form.test.ts": 2246,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/agent-session-stop-note-paging.test.ts": 1337,
|
||||
"src/main/native-chat/agent-session-wire/options/testing/at-rest-command-demand.test.ts": 43,
|
||||
"src/main/native-chat/agent-session-wire/providers/testing/hook-failure-provider-frame.test.ts": 234,
|
||||
"src/main/native-chat/agent-session-wire/publication/history/testing/json-string-shortening.test.ts": 47,
|
||||
"src/main/native-chat/agent-session-wire/providers/testing/provider-frame-disposition.test.ts": 82,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-accept-then-deliver.test.ts": 7805,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-acquire-isolation.test.ts": 1897,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-adopted-import.test.ts": 2881,
|
||||
@@ -2826,7 +2817,6 @@
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-clear-queued-command.test.ts": 2168,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-close-aborts-start.test.ts": 1806,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-codex-held-goal.test.ts": 2081,
|
||||
"src/main/native-chat/agent-session-wire/publication/subscribers/testing/structured-agent-session-command-publication.test.ts": 1350,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-create-adoption-budget.test.ts": 4134,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-create-adoption.test.ts": 1670,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-current-requests.test.ts": 3325,
|
||||
@@ -2844,7 +2834,6 @@
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-late-settlement-cancelled.test.ts": 2020,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-lease-renewer.test.ts": 875,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-newer-content-host.test.ts": 1997,
|
||||
"src/main/native-chat/agent-session-wire/startup/testing/structured-agent-session-operation-start-refusal.test.ts": 1210,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-options-read-scaling.test.ts": 1006,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-prompt-cancel-repeat.test.ts": 1493,
|
||||
"src/main/native-chat/agent-session-wire/providers/testing/structured-agent-session-provider-naming.test.ts": 297,
|
||||
@@ -2866,14 +2855,11 @@
|
||||
"src/main/native-chat/agent-session-wire/journal-ingress/testing/structured-agent-session-resolved-append.test.ts": 683,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-restart-audience.test.ts": 2056,
|
||||
"src/main/native-chat/agent-session-wire/recovery/testing/structured-agent-session-restart-cap.test.ts": 731,
|
||||
"src/main/native-chat/agent-session-wire/recovery/testing/structured-agent-session-restart-context-clear.test.ts": 538,
|
||||
"src/main/native-chat/agent-session-wire/recovery/testing/structured-agent-session-restart-continuation.test.ts": 533,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-restart-failure-filing.test.ts": 2350,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-restart-offer-after-reattach.test.ts": 1903,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-restart-offer-endings.test.ts": 2091,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-restart-offer-newer-orca.test.ts": 2824,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-restart-ownership.test.ts": 4640,
|
||||
"src/main/native-chat/agent-session-wire/recovery/testing/structured-agent-session-restart-resume.test.ts": 575,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-restart-teardown.test.ts": 2163,
|
||||
"src/main/native-chat/agent-session-wire/observations/testing/structured-agent-session-resumed-child-owner.test.ts": 682,
|
||||
"src/main/native-chat/agent-session-wire/rewind/testing/structured-agent-session-rewind-provenance.test.ts": 626,
|
||||
@@ -2885,18 +2871,11 @@
|
||||
"src/main/native-chat/agent-session-wire/termination/testing/structured-agent-session-stale-turn-verdict.test.ts": 305,
|
||||
"src/main/native-chat/agent-session-wire/termination/testing/structured-agent-session-stale-verdict-persistence.test.ts": 972,
|
||||
"src/main/native-chat/agent-session-wire/termination/testing/structured-agent-session-stale-verdict-prompts.test.ts": 665,
|
||||
"src/main/native-chat/agent-session-wire/startup/testing/structured-agent-session-start-flight.test.ts": 48,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-startup-attempt-reports.test.ts": 1857,
|
||||
"src/main/native-chat/agent-session-wire/observations/testing/structured-agent-session-status-feed-launch-directory.test.ts": 856,
|
||||
"src/main/native-chat/agent-session-wire/observations/testing/structured-agent-session-status-feed-stopping.test.ts": 831,
|
||||
"src/main/native-chat/agent-session-wire/observations/testing/structured-agent-session-status-feed.test.ts": 1849,
|
||||
"src/main/native-chat/agent-session-wire/observations/testing/structured-agent-session-status-reentry.test.ts": 1147,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-stop-event.test.ts": 1644,
|
||||
"src/main/native-chat/agent-session-wire/operations/testing/structured-agent-session-stop-note-opened-turn.test.ts": 1446,
|
||||
"src/main/native-chat/agent-session-wire/operations/testing/structured-agent-session-stop-note-withdrawn-send.test.ts": 1566,
|
||||
"src/main/native-chat/agent-session-wire/publication/subscribers/testing/structured-agent-session-subscriber-background-tasks.test.ts": 829,
|
||||
"src/main/native-chat/agent-session-wire/publication/subscribers/testing/structured-agent-session-subscriber-oversized-removal.test.ts": 717,
|
||||
"src/main/native-chat/agent-session-wire/publication/subscribers/testing/structured-agent-session-subscriber-publication.test.ts": 732,
|
||||
"src/main/native-chat/agent-session-wire/recovery/testing/structured-agent-session-target-reconcile.test.ts": 786,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-terminal-writer-acquire.test.ts": 5574,
|
||||
"src/main/native-chat/agent-session-wire/operations/testing/structured-agent-session-thread-goal.test.ts": 3029,
|
||||
@@ -2908,7 +2887,6 @@
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-conversation-command-child-work.test.ts": 1810,
|
||||
"src/main/native-chat/agent-session-wire/host/testing/structured-conversation-command.test.ts": 2989,
|
||||
"src/main/native-chat/agent-session-wire/rewind/testing/structured-rewind-cut.test.ts": 672,
|
||||
"src/main/native-chat/agent-session-wire/providers/testing/unhandled-provider-frame.test.ts": 231,
|
||||
"src/main/native-chat/chat-launcher-command.test.ts": 651,
|
||||
"src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts": 373,
|
||||
"src/main/native-chat/host-readable-transcript-path.test.ts": 150,
|
||||
@@ -6533,7 +6511,6 @@
|
||||
"src/renderer/src/components/pull-request-page/page/use-details.test.tsx": 745,
|
||||
"src/renderer/src/components/quick-open-file-history.test.ts": 1716,
|
||||
"src/renderer/src/components/quick-open-file-list-host-name-filter.react.test.tsx": 1941,
|
||||
"src/renderer/src/components/quick-open-file-list-metadata.react.test.tsx": 2100,
|
||||
"src/renderer/src/components/quick-open-file-list.react.test.tsx": 2215,
|
||||
"src/renderer/src/components/quick-open-file-list.test.ts": 54,
|
||||
"src/renderer/src/components/quick-open-history-ranking.test.ts": 112,
|
||||
@@ -6616,14 +6593,12 @@
|
||||
"src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.cancel.test.ts": 1992,
|
||||
"src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.test.ts": 900,
|
||||
"src/renderer/src/components/right-sidebar/source-control/listing/use-file-projection-work.test.tsx": 703,
|
||||
"src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx": 1912,
|
||||
"src/renderer/src/components/right-sidebar/source-control/panel/use-panel-view-state.test.tsx": 866,
|
||||
"src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-operation-target.test.ts": 1735,
|
||||
"src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-jira-title.test.tsx": 1956,
|
||||
"src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx": 2720,
|
||||
"src/renderer/src/components/right-sidebar/source-control/review/use-review-push-target.test.tsx": 268,
|
||||
"src/renderer/src/components/right-sidebar/source-control/sync/git-history-format.test.ts": 266,
|
||||
"src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-wsl-wake.test.tsx": 2095,
|
||||
"src/renderer/src/components/right-sidebar/ssh-port-forward-dialog.ime-enter.test.tsx": 585,
|
||||
"src/renderer/src/components/right-sidebar/ai-vault/search/use-ai-vault-search.test.tsx": 688,
|
||||
"src/renderer/src/components/right-sidebar/checks-panel/refresh/use-checks-panel-terminal-worktree.test.ts": 2116,
|
||||
@@ -6749,7 +6724,6 @@
|
||||
"src/renderer/src/components/sidebar/CommentMarkdown.parse-validity.test.tsx": 7186,
|
||||
"src/renderer/src/components/sidebar/CommentMarkdown.unknown-html.test.tsx": 3140,
|
||||
"src/renderer/src/components/sidebar/CommentMarkdown.word-fade.test.tsx": 2113,
|
||||
"src/renderer/src/components/sidebar/CommentMermaidBlock.system-theme.test.tsx": 389,
|
||||
"src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteNestedWorktreesDialog.test.tsx": 1807,
|
||||
"src/renderer/src/components/sidebar/HostRemoveDialog.test.tsx": 6650,
|
||||
"src/renderer/src/components/sidebar/LocalGitToolchainScanBanner.test.tsx": 2025,
|
||||
@@ -6789,7 +6763,6 @@
|
||||
"src/renderer/src/components/sidebar/worktree-list/grouping/host-section-reveal.test.tsx": 2294,
|
||||
"src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts": 52,
|
||||
"src/renderer/src/components/sidebar/worktree-list/listing/manual-sort-switch-toast.test.ts": 1753,
|
||||
"src/renderer/src/components/sidebar/markdown-top-level-blocks.test.ts": 543,
|
||||
"src/renderer/src/components/sidebar/mobile-sidebar-onboarding-badge.test.ts": 230,
|
||||
"src/renderer/src/components/sidebar/worktree-list/grouping/pinned-section-worktrees.test.ts": 446,
|
||||
"src/renderer/src/components/sidebar/workspace-actions/deletion/preserved-branch-batch-toast.test.tsx": 627,
|
||||
@@ -8951,11 +8924,6 @@
|
||||
"src/shared/host-balanced-listing-scaling.test.ts": 64,
|
||||
"src/shared/host-setting-overrides.test.ts": 55,
|
||||
"src/shared/host-spawn-admission.test.ts": 183,
|
||||
"src/shared/source-control/hosted-review/hosted-review-create-confirmation.test.ts": 43,
|
||||
"src/shared/source-control/hosted-review/hosted-review-create-seed.test.ts": 83,
|
||||
"src/shared/source-control/hosted-review/hosted-review-creation-providers.test.ts": 35,
|
||||
"src/shared/source-control/hosted-review/hosted-review-github.test.ts": 40,
|
||||
"src/shared/source-control/hosted-review/hosted-review-push-target.test.ts": 52,
|
||||
"src/shared/source-control/hosted-review/hosted-review-refs.test.ts": 57,
|
||||
"src/shared/html-file-url.test.ts": 49,
|
||||
"src/shared/image-data-uri.test.ts": 59,
|
||||
@@ -9244,11 +9212,7 @@
|
||||
"src/shared/powershell-native-argument.test.ts": 34,
|
||||
"src/shared/powershell-native-argument.windows.test.ts": 67,
|
||||
"src/shared/source-control/review-comments/pr-bot-author-overrides.test.ts": 76,
|
||||
"src/shared/source-control/checks/pr-check-severity-order.test.ts": 51,
|
||||
"src/shared/source-control/checks/pr-checks-fix-prompt.test.ts": 93,
|
||||
"src/shared/source-control/review-comments/pr-comment-audience.test.ts": 51,
|
||||
"src/shared/source-control/review-comments/pr-comment-groups.test.ts": 35,
|
||||
"src/shared/source-control/review-comments/pr-comment-time.test.ts": 40,
|
||||
"src/shared/preferred-git-remote.test.ts": 42,
|
||||
"src/shared/priority-semaphore.test.ts": 43,
|
||||
"src/shared/process-growth-breaker.test.ts": 47,
|
||||
@@ -9432,14 +9396,8 @@
|
||||
"src/shared/skill-package-manifest.test.ts": 188,
|
||||
"src/shared/skill-path-containment.test.ts": 49,
|
||||
"src/shared/sleeping-record-legacy-pane-key.test.ts": 59,
|
||||
"src/shared/source-control/decisions/source-control-create-review-intent.test.ts": 54,
|
||||
"src/shared/source-control/listing/source-control-discard-all-sequence.test.ts": 50,
|
||||
"src/shared/source-control/listing/source-control-entry-actions.test.ts": 54,
|
||||
"src/shared/source-control/listing/source-control-group-order.test.ts": 31,
|
||||
"src/shared/source-control/decisions/source-control-next-step-decision.test.ts": 99,
|
||||
"src/shared/source-control/decisions/source-control-primary-action-decision.test.ts": 67,
|
||||
"src/shared/source-control/recovery/source-control-push-failure.test.ts": 51,
|
||||
"src/shared/source-control/listing/source-control-section-order.test.ts": 63,
|
||||
"src/shared/source-control/listing/source-control-tree.test.ts": 63,
|
||||
"src/shared/source-scan/source-tree-scan.test.ts": 50,
|
||||
"src/shared/source-scan/source-tree-walk.test.ts": 62,
|
||||
|
||||
@@ -152,7 +152,6 @@ const NODE_RUNTIME_INCLUDE = [
|
||||
'src/renderer/src/components/automations/use-automation-runs-dashboard-payload.test.tsx',
|
||||
'src/main/native-chat/agent-session-journal/**/*.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-terminal-writer-acquire.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/observations/testing/structured-agent-session-status-feed.test.ts',
|
||||
'src/main/runtime/agent-session/records/agent-session-conversation-name-store.test.ts',
|
||||
'src/main/runtime/rpc/ws-transport.test.ts',
|
||||
'src/main/runtime/rpc/dispatcher-transport-parity.test.ts',
|
||||
|
||||
@@ -149,29 +149,6 @@ beforeEach(() => {
|
||||
held.errors = []
|
||||
})
|
||||
|
||||
describe('a dictation the phone can hear', () => {
|
||||
it('records without the computer and inserts the phone’s text', async () => {
|
||||
const rpc = createFakeRpcClient()
|
||||
const sent: SentRequest[] = []
|
||||
const transcripts: string[] = []
|
||||
mount(rpc, (text) => transcripts.push(text))
|
||||
await act(async () => {
|
||||
await dictation().start()
|
||||
})
|
||||
expect(dictation().status).toBe('recording')
|
||||
speak([1, 2, 3, 4])
|
||||
seam.finish = { text: ' hello phone ', failure: null }
|
||||
await act(async () => {
|
||||
const stopped = dictation().stop()
|
||||
await pump(rpc, sent)
|
||||
await stopped
|
||||
})
|
||||
expect(transcripts).toEqual(['hello phone'])
|
||||
expect(sent).toEqual([])
|
||||
expect(dictation().status).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a phone recognizer that fails at the finish', () => {
|
||||
it('replays what was said to the computer and inserts its text', async () => {
|
||||
const rpc = createFakeRpcClient()
|
||||
@@ -280,19 +257,3 @@ describe('a stop while a failed phone dictation is reaching the computer', () =>
|
||||
expect(dictation().status).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a dictation when the user chose Computer', () => {
|
||||
it('goes to the computer and never arms the phone', async () => {
|
||||
seam.preference = 'computer'
|
||||
const rpc = createFakeRpcClient()
|
||||
const sent: SentRequest[] = []
|
||||
mount(rpc, () => {})
|
||||
await act(async () => {
|
||||
const started = dictation().start()
|
||||
await pump(rpc, sent)
|
||||
await started
|
||||
})
|
||||
expect(sent.map((request) => request.method)).toEqual(['speech.dictation.start'])
|
||||
expect(seam.calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,11 +24,6 @@ describe('a page asking for the microphone when the user chose This phone', () =
|
||||
await server.serve({ action: 'cancel' })
|
||||
expect(await server.mayCapture()).toBe(false)
|
||||
})
|
||||
|
||||
it('is allowed under Automatic, where the computer is a fallback', async () => {
|
||||
const server = createNativeSpeechServer(engineWith('automatic'))
|
||||
expect(await server.mayCapture()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a recognizer another page took over', () => {
|
||||
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionExecutionLocation } from '../../../../../shared/agent-session/lifecycle/agent-session-record'
|
||||
import type { AgentSessionStatusEvent } from '../../../../../shared/agent-session/agent-session-wire'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants'
|
||||
import { codexProviderHandle } from '../../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
|
||||
import { createTrackedJournalOpener } from '../../../agent-session-journal/store-lifecycle/journal-host-database-test-support'
|
||||
import type { AgentSessionRecordStore } from '../../../../runtime/agent-session/records/agent-session-record-store'
|
||||
import { openTestAgentSessionRecordStore } from '../../../../runtime/agent-session/records/agent-session-record-store-test-harness'
|
||||
import { createStructuredAgentSessionLogger } from '../../host/structured-agent-session-logger'
|
||||
import { StructuredAgentSessionStatusFeed } from '../structured-agent-session-status-feed'
|
||||
|
||||
const SESSION = 'session-alpha'
|
||||
const NOW = 1_800_000_000_000
|
||||
|
||||
const FLOATING: AgentSessionExecutionLocation = {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
workspaceKind: 'folder'
|
||||
}
|
||||
const WORKTREE: AgentSessionExecutionLocation = {
|
||||
...FLOATING,
|
||||
workspaceId: 'repo-1::/repos/one',
|
||||
workspaceKind: 'git-worktree'
|
||||
}
|
||||
|
||||
let root: string
|
||||
let store: AgentSessionRecordStore
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-status-feed-workspace-path-'))
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function feedFor(location: AgentSessionExecutionLocation) {
|
||||
await store.reserveOwner({
|
||||
sessionId: SESSION,
|
||||
location,
|
||||
provider: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
|
||||
expectedFence: null,
|
||||
spawnToken: 'spawn-a',
|
||||
claimKeyId: 'key-1',
|
||||
handoffOperationId: null,
|
||||
probe: { outcome: 'reservation-unused' },
|
||||
operation: {
|
||||
callerKey: 'client-1',
|
||||
operationId: `${NOW}-${'1'.padStart(32, '0')}`,
|
||||
fingerprint: 'fp-1'
|
||||
},
|
||||
now: NOW
|
||||
})
|
||||
const journal = await journals.open({
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: location.workspaceId,
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
},
|
||||
stateDirectory: root
|
||||
})
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
sessions: new Map([[SESSION, { journal, params: { location, provider: 'codex' } }]]),
|
||||
getConversation: (sessionId) => store.getConversation(sessionId),
|
||||
now: () => NOW
|
||||
})
|
||||
const events: AgentSessionStatusEvent[] = []
|
||||
feed.subscribe({ id: 'renderer', emit: (event) => events.push(event) })
|
||||
return { feed, events }
|
||||
}
|
||||
|
||||
function lastSummary(events: readonly AgentSessionStatusEvent[]) {
|
||||
const event = events.at(-1)
|
||||
if (event?.type === 'status') {
|
||||
return event.session
|
||||
}
|
||||
if (event?.type === 'snapshot') {
|
||||
return event.sessions.find((session) => session.sessionId === SESSION)
|
||||
}
|
||||
throw new Error('no status publication')
|
||||
}
|
||||
|
||||
describe('status feed pinned launch directory', () => {
|
||||
it('publishes a floating session its pinned folder once the launch pins it', async () => {
|
||||
const { feed, events } = await feedFor(FLOATING)
|
||||
expect(lastSummary(events)).toMatchObject({ sessionId: SESSION })
|
||||
expect(lastSummary(events)).not.toHaveProperty('launchDirectory')
|
||||
|
||||
await store.pinLaunchDirectory(SESSION, '/home/me/original-floating')
|
||||
feed.publish(SESSION)
|
||||
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'status',
|
||||
session: { sessionId: SESSION, launchDirectory: '/home/me/original-floating' }
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the pin for a worktree session, whose id is where it launches', async () => {
|
||||
const { feed, events } = await feedFor(WORKTREE)
|
||||
await store.pinLaunchDirectory(SESSION, '/repos/one')
|
||||
feed.publish(SESSION)
|
||||
|
||||
expect(lastSummary(events)).toMatchObject({ sessionId: SESSION })
|
||||
expect(lastSummary(events)).not.toHaveProperty('launchDirectory')
|
||||
})
|
||||
})
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
import { agentSessionConversationView } from '../../../../../shared/agent-session/lifecycle/agent-session-conversation-view'
|
||||
import { codexProviderHandle } from '../../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { AgentSessionStatusSummary } from '../../../../../shared/agent-session/agent-session-wire'
|
||||
import { agentSessionRecordFixture } from '../../../../../shared/agent-session/lifecycle/agent-session-record.test-fixture'
|
||||
import { createTrackedJournalOpener } from '../../../agent-session-journal/store-lifecycle/journal-host-database-test-support'
|
||||
import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session'
|
||||
import { createStructuredAgentSessionLogger } from '../../host/structured-agent-session-logger'
|
||||
import { StructuredAgentSessionStatusFeed } from '../structured-agent-session-status-feed'
|
||||
|
||||
const SESSION = 'status-session'
|
||||
|
||||
let root: string
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-status-feed-stopping-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("the status feed's Stopping", () => {
|
||||
it('publishes it while the stopped turn runs, and drops it when the host lets go', async () => {
|
||||
const journal = await journals.open({
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
},
|
||||
stateDirectory: join(root, SESSION)
|
||||
})
|
||||
const fence = agentSessionRecordFixture().lease.runtimeFence
|
||||
const published: AgentSessionStatusSummary[] = []
|
||||
const session = indexedStatusFeedSession({
|
||||
journal,
|
||||
child: { phase: 'ready', generation: 'child-1', fence }
|
||||
})
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
sessions: new Map([[SESSION, session]]),
|
||||
statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }),
|
||||
getConversation: () => agentSessionConversationView(agentSessionRecordFixture()),
|
||||
now: () => 1_000
|
||||
})
|
||||
await journal.appendItem(
|
||||
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
|
||||
{ kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1_000 },
|
||||
{ fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
feed.publish(SESSION)
|
||||
expect(published.at(-1)).toMatchObject({ status: 'working' })
|
||||
expect(published.at(-1)).not.toHaveProperty('stopping')
|
||||
|
||||
await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, fence)
|
||||
feed.publish(SESSION)
|
||||
expect(published.at(-1)).toMatchObject({ status: 'working', stopping: true })
|
||||
|
||||
feed.revokeLive(SESSION)
|
||||
expect(published.at(-1)).toMatchObject({ status: 'working' })
|
||||
expect(published.at(-1)).not.toHaveProperty('stopping')
|
||||
})
|
||||
|
||||
it('reads a turn waiting on the person as attention, never Stopping', async () => {
|
||||
const journal = await journals.open({
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
},
|
||||
stateDirectory: join(root, SESSION)
|
||||
})
|
||||
const fence = agentSessionRecordFixture().lease.runtimeFence
|
||||
const published: AgentSessionStatusSummary[] = []
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]),
|
||||
statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }),
|
||||
getConversation: () => agentSessionConversationView(agentSessionRecordFixture()),
|
||||
now: () => 1_000
|
||||
})
|
||||
const turn = { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1' } as const
|
||||
await journal.appendItem(
|
||||
{ ...turn, ordinal: 0 },
|
||||
{ kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1_000 },
|
||||
{ fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, fence)
|
||||
await journal.appendItem(
|
||||
{ ...turn, ordinal: 1 },
|
||||
{
|
||||
kind: 'approval',
|
||||
title: 'Run command?',
|
||||
detail: null,
|
||||
options: [{ id: 'yes', label: 'Allow' }],
|
||||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||||
},
|
||||
{ fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
|
||||
feed.publish(SESSION)
|
||||
|
||||
expect(published.at(-1)).toMatchObject({ status: 'attention' })
|
||||
expect(published.at(-1)).not.toHaveProperty('stopping')
|
||||
})
|
||||
})
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
import { savedStructuredAgentSessionStatus } from '../structured-agent-session-saved-status'
|
||||
import { releasedStructuredStatusSummary } from '../structured-agent-session-status-summary'
|
||||
import { agentSessionConversationView } from '../../../../../shared/agent-session/lifecycle/agent-session-conversation-view'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../../../../shared/agent-session/lifecycle/agent-session-record'
|
||||
import { agentSessionRecordFixture } from '../../../../../shared/agent-session/lifecycle/agent-session-record.test-fixture'
|
||||
import type { AgentSessionStatusEvent } from '../../../../../shared/agent-session/agent-session-wire'
|
||||
import type { AgentChildWorkView } from '../../../../../shared/agent-status/child-work/agent-status-child-work-view'
|
||||
import { createTrackedJournalOpener } from '../../../agent-session-journal/store-lifecycle/journal-host-database-test-support'
|
||||
import { indexedStatusFeedSession as indexed } from './structured-agent-session-status-feed-test-session'
|
||||
import { StructuredAgentSessionStatusFeed } from '../structured-agent-session-status-feed'
|
||||
import type { StructuredAgentSessionStatusFeedDeps } from '../structured-agent-session-status-feed-deps'
|
||||
import type { StructuredAgentSessionStatusSink } from '../structured-agent-session-status-ownership'
|
||||
import { createStructuredAgentSessionLogger } from '../../host/structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
|
||||
|
||||
const SESSION = 'status-session'
|
||||
const USER_IDENTITY = {
|
||||
provider: 'codex',
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
ordinal: 1
|
||||
} as const
|
||||
|
||||
let root: string
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-agent-status-feed-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function openJournal(sessionId = SESSION, now?: () => number) {
|
||||
return journals.open({
|
||||
identity: {
|
||||
sessionId,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
},
|
||||
now,
|
||||
stateDirectory: join(root, sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
function feedFor(
|
||||
sessions: Map<string, Parameters<typeof indexed>[0]>,
|
||||
record: Partial<AgentSessionRecord> | null = null,
|
||||
onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'],
|
||||
readChildWork?: () => AgentChildWorkView[],
|
||||
statusSink?: StructuredAgentSessionStatusSink
|
||||
) {
|
||||
let now = 1_000
|
||||
const sink =
|
||||
statusSink ??
|
||||
(readChildWork ? { publish: () => {}, forget: () => {}, readChildWork } : undefined)
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
...(onStatusChanged ? { onStatusChanged } : {}),
|
||||
...(sink ? { statusSink: () => sink } : {}),
|
||||
sessions: {
|
||||
get: (sessionId: string) => {
|
||||
const session = sessions.get(sessionId)
|
||||
return session ? indexed(session) : undefined
|
||||
},
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const [sessionId, session] of sessions) {
|
||||
yield [sessionId, indexed(session)] as const
|
||||
}
|
||||
}
|
||||
} as unknown as ReadonlyMap<string, ReturnType<typeof indexed>>,
|
||||
// A partial record still has a lease: the feed reads the conversation's fence off it.
|
||||
getConversation: () =>
|
||||
record ? agentSessionConversationView({ ...agentSessionRecordFixture(), ...record }) : null,
|
||||
now: () => (now += 1)
|
||||
})
|
||||
const events: AgentSessionStatusEvent[] = []
|
||||
const dispose = feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) })
|
||||
return { feed, events, dispose }
|
||||
}
|
||||
|
||||
describe('StructuredAgentSessionStatusFeed', () => {
|
||||
it('ends a closed subscriber and keeps publishing to the rest', async () => {
|
||||
const journal = await openJournal()
|
||||
const { feed, events, dispose } = feedFor(new Map([[SESSION, { journal }]]))
|
||||
const others: AgentSessionStatusEvent[] = []
|
||||
feed.subscribe({ id: 'list-2', emit: (event) => others.push(event) })
|
||||
|
||||
dispose()
|
||||
await journal.appendItem(
|
||||
USER_IDENTITY,
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
feed.publish(SESSION)
|
||||
|
||||
expect(events.at(-1)).toEqual({ type: 'end' })
|
||||
expect(others.at(-1)).toEqual({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({ status: 'idle' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Broadcast cache retains closed history; roster reads must intersect it with live sessions.
|
||||
describe('the status sink sees the roster the broadcast cache deliberately lacks', () => {
|
||||
it('keeps publishing to subscribers when the sink throws', async () => {
|
||||
const journal = await openJournal()
|
||||
const sink: StructuredAgentSessionStatusSink = {
|
||||
publish: () => {
|
||||
throw new Error('store down')
|
||||
},
|
||||
forget: () => {
|
||||
throw new Error('store down')
|
||||
}
|
||||
}
|
||||
const { feed, events } = feedFor(
|
||||
new Map([[SESSION, { journal }]]),
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
sink
|
||||
)
|
||||
await journal.appendItem(
|
||||
USER_IDENTITY,
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
feed.publish(SESSION, journal)
|
||||
expect(() => feed.forget(SESSION)).not.toThrow()
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({ sessionId: SESSION, status: 'idle' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes a saved mode-only change without journal activity and retains it in reconnect summaries', async () => {
|
||||
const journal = await openJournal()
|
||||
const sessions = new Map<string, Parameters<typeof indexed>[0]>([[SESSION, { journal }]])
|
||||
const record: Partial<AgentSessionRecord> = { options: {} }
|
||||
const { feed, events, dispose } = feedFor(sessions, record)
|
||||
const cursor = journal.cursor()
|
||||
record.options = { interactionMode: 'plan' }
|
||||
feed.publish(SESSION, journal)
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({ interactionMode: 'plan' })
|
||||
})
|
||||
expect(journal.cursor()).toEqual(cursor)
|
||||
const saved = savedStructuredAgentSessionStatus(
|
||||
agentSessionConversationView({ ...agentSessionRecordFixture(), options: record.options })
|
||||
)
|
||||
expect(saved.interactionMode).toBe('plan')
|
||||
expect(releasedStructuredStatusSummary(saved).interactionMode).toBe('plan')
|
||||
record.options = { interactionMode: 'default' }
|
||||
feed.publish(SESSION, journal)
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({ interactionMode: 'default' })
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import { codexProviderHandle } from '../../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { AgentSessionJournalIdentity } from '../../../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
import { createTrackedJournalOpener } from '../../../agent-session-journal/store-lifecycle/journal-host-database-test-support'
|
||||
import type { StructuredAgentSessionAdapter } from '../../providers/structured-agent-session-adapter'
|
||||
import { NO_STRUCTURED_AGENTS } from '../../providers/testing/structured-agent-session-adapter-router-test-support'
|
||||
import { structuredAgentSessionStopNoteIdentity } from '../structured-agent-session-command-turn'
|
||||
import { createStructuredAgentSessionLogger } from '../../host/structured-agent-session-logger'
|
||||
import '../structured-agent-session-turns'
|
||||
import { performCancel } from '../structured-agent-session-turns-cancel'
|
||||
import type { AgentSessionTurnContext } from '../structured-agent-session-turns'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
}
|
||||
const TURN = { provider: 'codex' as const, threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }
|
||||
|
||||
let root: string | null = null
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
root = null
|
||||
}
|
||||
})
|
||||
|
||||
async function stopWhileTheTurnOpens(
|
||||
outcome: Awaited<ReturnType<StructuredAgentSessionAdapter['cancelTurn']>>
|
||||
) {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-stop-note-opened-turn-'))
|
||||
const journal = await journals.open({ identity: IDENTITY, stateDirectory: root })
|
||||
const ctx: AgentSessionTurnContext = {
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: {
|
||||
open: async () => ({
|
||||
reportedOptions: { model: '' }
|
||||
}),
|
||||
acquire: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
closeSession: vi.fn(),
|
||||
cancelTurn: vi.fn(async () => {
|
||||
await journal.appendItem(
|
||||
TURN,
|
||||
{ kind: 'turn', turnId: 'turn-1', state: 'running' },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
return outcome
|
||||
}),
|
||||
answerPrompt: vi.fn(),
|
||||
setOption: vi.fn()
|
||||
},
|
||||
persistOptions: async () => undefined,
|
||||
resolvedBy: 'client-1',
|
||||
publish: vi.fn(),
|
||||
now: () => 1
|
||||
}
|
||||
await performCancel(ctx, { clientOperationId: 'stop-1', endSession: () => {} })
|
||||
return journal
|
||||
.snapshot()
|
||||
.items.flatMap((item) =>
|
||||
item.body.kind === 'status' ? [{ itemId: item.itemId, turnScope: item.turnScope }] : []
|
||||
)
|
||||
}
|
||||
|
||||
describe('a Stop that waited for a turn to open', () => {
|
||||
it('keeps the request before the turn that opens during interruption', async () => {
|
||||
expect(await stopWhileTheTurnOpens({ cancelled: true, turnId: 'turn-1' })).toEqual([
|
||||
{
|
||||
itemId: agentJournalItemKey(structuredAgentSessionStopNoteIdentity('stop-1')),
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it("leaves a refused Stop's row with the conversation, as before", async () => {
|
||||
expect(
|
||||
await stopWhileTheTurnOpens({
|
||||
cancelled: false,
|
||||
refusal: { detail: { text: 'failed to interrupt turn', audience: 'person' } }
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
itemId: agentJournalItemKey(structuredAgentSessionStopNoteIdentity('stop-1')),
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
// Accepted Stop retains its request note even when child exit takes back the send.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { agentSessionFailureFact } from '../../../../../shared/agent-session/failures/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../../../shared/agent-session/failures/agent-session-failure-words'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { AgentSessionJournalIdentity } from '../../../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
import { codexProviderHandle } from '../../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
|
||||
import { createTrackedJournalOpener } from '../../../agent-session-journal/store-lifecycle/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from '../../host/structured-agent-session-logger'
|
||||
import '../structured-agent-session-turns'
|
||||
import { performCancel } from '../structured-agent-session-turns-cancel'
|
||||
import type { AgentSessionTurnContext } from '../structured-agent-session-turns'
|
||||
import { NO_STRUCTURED_AGENTS } from '../../providers/testing/structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
}
|
||||
|
||||
let root: string | null = null
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
root = null
|
||||
}
|
||||
})
|
||||
|
||||
async function stopEndingTheChild(options: {
|
||||
turnRunning: boolean
|
||||
takesSendBack: boolean
|
||||
earlierProcessSendInDoubt?: boolean
|
||||
}) {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-stop-note-withdrawn-'))
|
||||
const journal = await journals.open({ identity: IDENTITY, stateDirectory: root })
|
||||
if (options.turnRunning) {
|
||||
await journal.appendItem(
|
||||
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
|
||||
{ kind: 'turn', turnId: 'turn-1', state: 'running' },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
}
|
||||
if (options.earlierProcessSendInDoubt) {
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'send-0',
|
||||
payloadFingerprint: 'send-0',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'before the crash' }] },
|
||||
fence: 1
|
||||
})
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'send-0',
|
||||
state: 'unknown',
|
||||
reason: 'host_restarted',
|
||||
fence: 1,
|
||||
recovered: true
|
||||
})
|
||||
}
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'send-1',
|
||||
payloadFingerprint: 'send-1',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'look around' }] },
|
||||
fence: 1
|
||||
})
|
||||
const ctx: AgentSessionTurnContext = {
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: {
|
||||
open: async () => ({
|
||||
reportedOptions: { model: '' }
|
||||
}),
|
||||
acquire: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
closeSession: vi.fn(),
|
||||
cancelTurn: vi.fn(async () => ({
|
||||
cancelled: false,
|
||||
refusal: { detail: { text: 'failed to interrupt turn', audience: 'person' as const } }
|
||||
})),
|
||||
answerPrompt: vi.fn(),
|
||||
setOption: vi.fn()
|
||||
},
|
||||
persistOptions: async () => undefined,
|
||||
resolvedBy: 'client-1',
|
||||
publish: vi.fn(),
|
||||
now: () => 1
|
||||
}
|
||||
const result = await performCancel(ctx, {
|
||||
clientOperationId: 'stop-1',
|
||||
stopChild: async () => {
|
||||
if (options.takesSendBack) {
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'send-1',
|
||||
state: 'rejected',
|
||||
...agentSessionFailureWords(agentSessionFailureFact('cancelled'), {
|
||||
surface: 'rejection'
|
||||
}),
|
||||
fence: 1,
|
||||
recovered: true
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
const rows = journal
|
||||
.snapshot()
|
||||
.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : []))
|
||||
return { cancelled: result.ok && result.value.cancelled, rows }
|
||||
}
|
||||
|
||||
describe('a Stop that ends the child after its interrupt failed', () => {
|
||||
it("keeps Stop when the only other send in doubt was an earlier process's", async () => {
|
||||
expect(
|
||||
await stopEndingTheChild({
|
||||
turnRunning: false,
|
||||
takesSendBack: true,
|
||||
earlierProcessSendInDoubt: true
|
||||
})
|
||||
).toEqual({ cancelled: true, rows: ['Cancellation requested.'] })
|
||||
})
|
||||
})
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import {
|
||||
captureStructuredExecutionOptions,
|
||||
openingMessageOptions
|
||||
} from '../structured-agent-session-execution-options'
|
||||
import { claudeAndCodexAgents } from '../../providers/testing/structured-agent-session-adapter-router-test-support'
|
||||
|
||||
it('inherits a saved Plan for older phone selections without adding model intent', () => {
|
||||
expect(
|
||||
captureStructuredExecutionOptions(
|
||||
{
|
||||
agents: claudeAndCodexAgents(),
|
||||
agent: 'claude',
|
||||
persistedOptions: { interactionMode: 'plan' }
|
||||
},
|
||||
undefined,
|
||||
{}
|
||||
)
|
||||
).toEqual({ options: { interactionMode: 'plan', fastMode: 'false' } })
|
||||
expect(openingMessageOptions({ interactionMode: 'plan' })).toEqual({
|
||||
options: { interactionMode: 'plan' }
|
||||
})
|
||||
})
|
||||
-150
@@ -1,150 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectStructuredItemToNativeChat } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import { unhandledProviderFrameJournalItem } from '../unhandled-provider-frame'
|
||||
|
||||
const CODEX_KIND = 'notification:hook/completed'
|
||||
const CLAUDE_KIND = 'message:system:hook_response'
|
||||
|
||||
// Shape captured from codex-cli 0.162.0 with a SessionStart command that exits 1.
|
||||
const CODEX_FAILURE = {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
run: {
|
||||
id: 'session-start:0:hooks.json',
|
||||
eventName: 'sessionStart',
|
||||
handlerType: 'command',
|
||||
executionMode: 'sync',
|
||||
scope: 'thread',
|
||||
sourcePath: 'hooks.json',
|
||||
source: 'user',
|
||||
status: 'failed',
|
||||
entries: [{ kind: 'error', text: 'hook exited with code 1' }]
|
||||
}
|
||||
}
|
||||
|
||||
describe('failed hook provider frames', () => {
|
||||
it('projects the Codex hook name and nested failure with its original Details', () => {
|
||||
const row = unhandledProviderFrameJournalItem('codex', CODEX_KIND, CODEX_FAILURE)
|
||||
expect(row).toMatchObject({
|
||||
classification: 'error-surface',
|
||||
body: {
|
||||
kind: 'status',
|
||||
tone: 'error',
|
||||
text: 'Codex SessionStart hook failed: hook exited with code 1'
|
||||
}
|
||||
})
|
||||
expect(row?.body.providerFrame?.payload.head).toBe(JSON.stringify(CODEX_FAILURE))
|
||||
expect(row?.body.providerFrame?.payload.truncated).toBe(false)
|
||||
if (!row) {
|
||||
throw new Error('Expected failed hook row')
|
||||
}
|
||||
expect(
|
||||
projectStructuredItemToNativeChat({
|
||||
itemId: 'failed-hook',
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: row.body
|
||||
})
|
||||
).toMatchObject({
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{ type: 'text', tone: 'error', text: row.body.text, providerFrame: row.body.providerFrame }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves multiple error entries without displaying hook progress as the error', () => {
|
||||
const payload = {
|
||||
run: {
|
||||
eventName: 'preToolUse',
|
||||
status: 'failed',
|
||||
statusMessage: 'Running hook',
|
||||
entries: [
|
||||
{ kind: 'info', text: 'Running hook' },
|
||||
{ kind: 'error', text: ' First failure ' },
|
||||
null,
|
||||
{ kind: 'error', text: 1 },
|
||||
{ kind: 'error', text: 'Second failure' }
|
||||
]
|
||||
}
|
||||
}
|
||||
expect(unhandledProviderFrameJournalItem('codex', CODEX_KIND, payload)?.body.text).toBe(
|
||||
'Codex PreToolUse hook failed: First failure\nSecond failure'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows Claude stderr and the exit code instead of the wire event name', () => {
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('claude', CLAUDE_KIND, {
|
||||
hook_name: 'PostToolUse:Bash',
|
||||
hook_event: 'PostToolUse',
|
||||
outcome: 'error',
|
||||
exit_code: 1,
|
||||
stderr: ' Script not found\n',
|
||||
output: 'Other output'
|
||||
})?.body
|
||||
).toMatchObject({
|
||||
tone: 'error',
|
||||
text: 'Claude PostToolUse:Bash hook failed (exit code 1): Script not found'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
stderr: ' ',
|
||||
output: 'Hook output',
|
||||
stdout: 'Other stdout',
|
||||
exit_code: null,
|
||||
expected: 'Hook output'
|
||||
},
|
||||
{ stderr: null, output: ' ', stdout: 'Hook stdout', exit_code: '1', expected: 'Hook stdout' },
|
||||
{
|
||||
stderr: {},
|
||||
output: null,
|
||||
message: 'Hook could not start',
|
||||
exit_code: 0,
|
||||
expected: 'Hook could not start'
|
||||
}
|
||||
])('uses available Claude output when stderr is empty: $expected', ({ expected, ...fields }) => {
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('claude', CLAUDE_KIND, {
|
||||
hook_event: 'SessionStart',
|
||||
outcome: 'error',
|
||||
...fields
|
||||
})?.body.text
|
||||
).toBe(`Claude SessionStart hook failed: ${expected}`)
|
||||
})
|
||||
|
||||
it('keeps successful hooks hidden even when they produce diagnostic output', () => {
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', CODEX_KIND, {
|
||||
run: { status: 'completed', entries: [{ kind: 'info', text: 'Hook completed' }] }
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('claude', CLAUDE_KIND, {
|
||||
hook_name: 'SessionStart',
|
||||
outcome: 'success',
|
||||
exit_code: 0,
|
||||
stderr: 'Diagnostic output'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps long hook error text whole and bounds only the raw Details', () => {
|
||||
const row = unhandledProviderFrameJournalItem(
|
||||
'codex',
|
||||
CODEX_KIND,
|
||||
{
|
||||
run: { status: 'failed', entries: [{ kind: 'error', text: 'x'.repeat(1000) }] }
|
||||
},
|
||||
{ inlineHeadBytes: 64 }
|
||||
)
|
||||
expect(row?.body.text).toBe(`Codex hook failed: ${'x'.repeat(1000)}`)
|
||||
expect(row?.body.providerFrame?.payload).toMatchObject({ truncated: true })
|
||||
expect(
|
||||
Buffer.byteLength(row?.body.providerFrame?.payload.head ?? '', 'utf8')
|
||||
).toBeLessThanOrEqual(64)
|
||||
})
|
||||
})
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyProviderFrame } from '../provider-frame-disposition'
|
||||
import { unhandledProviderFrameJournalItem } from '../unhandled-provider-frame'
|
||||
|
||||
describe('provider frame classification catalog', () => {
|
||||
it("surfaces a failed hook from the hook's own outcome field", () => {
|
||||
expect(
|
||||
classifyProviderFrame('codex', 'notification:hook/completed', {
|
||||
run: { status: 'failed' }
|
||||
})
|
||||
).toBe('error-surface')
|
||||
expect(
|
||||
classifyProviderFrame('claude', 'message:system:hook_response', {
|
||||
outcome: 'error',
|
||||
stderr: 'hook failed'
|
||||
})
|
||||
).toBe('error-surface')
|
||||
})
|
||||
|
||||
it('structurally diverts unknown future delta kinds from generic rows', () => {
|
||||
expect(classifyProviderFrame('codex', 'notification:item/newThing/outputDelta', {})).toBe(
|
||||
'stream-into-item'
|
||||
)
|
||||
expect(classifyProviderFrame('claude', 'message:future_delta', {})).toBe('stream-into-item')
|
||||
})
|
||||
|
||||
it('suppresses subAgentActivity once the roster renders it, but never collabAgentToolCall', () => {
|
||||
expect(
|
||||
classifyProviderFrame('codex', 'item:subAgentActivity', {
|
||||
id: 'a-1',
|
||||
kind: 'started',
|
||||
agentThreadId: 'thread-child',
|
||||
agentPath: '/root/list_directory'
|
||||
})
|
||||
// The roster already renders this lifecycle; raw fallback would duplicate it.
|
||||
).toBe('status-chrome')
|
||||
expect(
|
||||
classifyProviderFrame('codex', 'item:collabAgentToolCall', {
|
||||
id: 'c-1',
|
||||
tool: 'spawn',
|
||||
status: 'inProgress',
|
||||
senderThreadId: 'thread-root',
|
||||
receiverThreadIds: ['thread-child'],
|
||||
agentsStates: {}
|
||||
})
|
||||
).toBe('timeline-substantive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure is decided by kind, never by searching the payload', () => {
|
||||
it('writes no row for a Claude session start that lists MCP servers which failed to connect', () => {
|
||||
// Captured Claude 2.1.289 init includes failed MCP servers, not a failed session (#25477).
|
||||
const init = {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
mcp_servers: [
|
||||
{ name: 'MCP_DOCKER', status: 'failed', source: 'user' },
|
||||
{ name: 'codegraph', status: 'connected', source: 'user' },
|
||||
{ name: 'jira', status: 'failed', source: 'user' }
|
||||
]
|
||||
}
|
||||
expect(unhandledProviderFrameJournalItem('claude', 'message:system:init', init)).toBeNull()
|
||||
})
|
||||
|
||||
it('never turns a nested failure-looking value into an error row', () => {
|
||||
const failed = { task_id: 't-1', status: 'failed', error: 'exit code 1' }
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('claude', 'message:system:task_notification', failed)
|
||||
).toBeNull()
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', 'notification:mcpServer/startupStatus/updated', {
|
||||
name: 'github',
|
||||
status: 'failed',
|
||||
error: 'auth expired'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(unhandledProviderFrameJournalItem('claude', 'message:system:future', failed)).toBeNull()
|
||||
})
|
||||
})
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { unhandledProviderFrameJournalItem } from '../unhandled-provider-frame'
|
||||
|
||||
describe('unhandled provider frame journal fallback', () => {
|
||||
it('keeps a compact label and bounds the expandable payload without dropping it', () => {
|
||||
const item = unhandledProviderFrameJournalItem(
|
||||
'future-provider',
|
||||
'notification:new/event',
|
||||
{ body: 'abcdefghij' },
|
||||
{ inlineHeadBytes: 8 }
|
||||
)
|
||||
|
||||
expect(item).not.toBeNull()
|
||||
if (!item) {
|
||||
throw new Error('expected substantive provider frame')
|
||||
}
|
||||
expect(item.body).toMatchObject({
|
||||
kind: 'status',
|
||||
text: 'future-provider · notification:new/event',
|
||||
providerFrame: {
|
||||
provider: 'future-provider',
|
||||
kind: 'notification:new/event',
|
||||
payload: { byteLength: 21, truncated: true }
|
||||
}
|
||||
})
|
||||
expect(
|
||||
Buffer.byteLength(item.body.providerFrame?.payload.head ?? '', 'utf8')
|
||||
).toBeLessThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('turns an unserializable message-shaped payload into an explicit visible value', () => {
|
||||
const cyclic: { warning?: unknown } = {}
|
||||
cyclic.warning = cyclic
|
||||
|
||||
const item = unhandledProviderFrameJournalItem('codex', 'frame', cyclic)
|
||||
|
||||
expect(item?.body.text).toBe('codex · frame')
|
||||
expect(item?.body.providerFrame?.payload.head).toContain('unserializable payload')
|
||||
})
|
||||
})
|
||||
-49
@@ -31,55 +31,6 @@ function replyBytes(value: unknown): number {
|
||||
}
|
||||
|
||||
describe('conversation outline read', () => {
|
||||
it('drops previews before it drops entries, then drops the oldest entries, never passing the budget', () => {
|
||||
const items = Array.from({ length: 40 }, (_, index) => prompt(index + 1, 'x'.repeat(500)))
|
||||
const full = readAgentSessionConversationOutline(snapshot(items), Number.MAX_SAFE_INTEGER)
|
||||
const shortBudget = replyBytes(
|
||||
full.entries.map((entry) => ({ ...entry, preview: entry.preview.slice(0, 60) }))
|
||||
)
|
||||
expect(shortBudget).toBeLessThan(replyBytes(full.entries))
|
||||
const shortened = readAgentSessionConversationOutline(snapshot(items), shortBudget)
|
||||
expect(shortened.entries).toHaveLength(40)
|
||||
expect(shortened.entries.every((entry) => entry.preview.length === 60)).toBe(true)
|
||||
expect(replyBytes(shortened.entries)).toBeLessThanOrEqual(shortBudget)
|
||||
|
||||
const bare = readAgentSessionConversationOutline(
|
||||
snapshot(items),
|
||||
replyBytes(full.entries.map((entry) => ({ ...entry, preview: '' })))
|
||||
)
|
||||
expect(bare.entries).toHaveLength(40)
|
||||
expect(bare.entries.every((entry) => entry.preview === '')).toBe(true)
|
||||
|
||||
const budget = 10 * replyBytes(bare.entries[0])
|
||||
const trimmed = readAgentSessionConversationOutline(snapshot(items), budget)
|
||||
expect(trimmed.omittedEntries).toBeGreaterThan(0)
|
||||
expect(trimmed.entries.at(-1)?.sequence).toBe(40)
|
||||
expect(trimmed.entries[0]?.sequence).toBe(trimmed.omittedEntries + 1)
|
||||
expect(replyBytes(trimmed.entries)).toBeLessThanOrEqual(budget)
|
||||
})
|
||||
|
||||
it('carries the reply to each prompt, and drops replies before it shortens a preview', () => {
|
||||
const items = Array.from({ length: 10 }, (_, index): AgentJournalRenderItem[] => [
|
||||
prompt(index * 2 + 1, 'x'.repeat(100)),
|
||||
{
|
||||
itemId: `reply-${index}`,
|
||||
revision: 1,
|
||||
sequence: index * 2 + 2,
|
||||
observedAt: index * 2 + 2,
|
||||
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Answer' }] }
|
||||
}
|
||||
]).flat()
|
||||
const full = readAgentSessionConversationOutline(snapshot(items), Number.MAX_SAFE_INTEGER)
|
||||
expect(full.entries.map((entry) => entry.reply)).toEqual(Array(10).fill('Answer'))
|
||||
|
||||
// Budget includes array brackets and entry separators.
|
||||
const tight = readAgentSessionConversationOutline(snapshot(items), replyBytes(full.entries) - 2)
|
||||
expect(tight.entries.every((entry) => entry.reply === undefined)).toBe(true)
|
||||
expect(tight.entries.map((entry) => entry.preview)).toEqual(
|
||||
full.entries.map((entry) => entry.preview)
|
||||
)
|
||||
})
|
||||
|
||||
it('stays inside the history page budget for a journal far past it', () => {
|
||||
const items = Array.from({ length: 10_000 }, (_, index) =>
|
||||
prompt(index + 1, `${index} ${'y'.repeat(1_000)}`)
|
||||
|
||||
+2
-93
@@ -1,6 +1,3 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentJournalSubmissionKey } from '../../../../../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import type { AgentJournalItemIdentity } from '../../../../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
@@ -9,23 +6,10 @@ import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE
|
||||
} from '../../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { structuredAgentSessionPayloadFingerprint } from '../../../../../../shared/agent-session/lifecycle/structured-agent-session-mutation'
|
||||
import {
|
||||
closeTestJournalHostDatabase,
|
||||
createTrackedJournalOpener
|
||||
} from '../../../../agent-session-journal/store-lifecycle/journal-host-database-test-support'
|
||||
import { projectJournalBatch } from '../agent-session-journal-batch'
|
||||
|
||||
import { readAgentSessionHistory } from '../agent-session-history-page'
|
||||
|
||||
import {
|
||||
fixture,
|
||||
IDENTITY,
|
||||
appendItems,
|
||||
body,
|
||||
item,
|
||||
tick
|
||||
} from './agent-session-history-contract-test-fixture'
|
||||
|
||||
const outcomeJournals = createTrackedJournalOpener()
|
||||
import { fixture, IDENTITY, body, item } from './agent-session-history-contract-test-fixture'
|
||||
|
||||
describe('projectJournalBatch', () => {
|
||||
it('publishes every nested lifecycle mutation atomically at the outer cursor', async () => {
|
||||
@@ -85,81 +69,6 @@ describe('projectJournalBatch', () => {
|
||||
expect(tail.page.items.map((entry) => entry.body)).toEqual([body('one'), body('two')])
|
||||
})
|
||||
|
||||
it('accepts sparse outcome sequence groups', async () => {
|
||||
// The shared fixture pins the input format; sparse groups exist only in an outcome epoch.
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-history-outcomes-'))
|
||||
const journal = await outcomeJournals.open({
|
||||
identity: IDENTITY,
|
||||
stateDirectory: root,
|
||||
now: tick
|
||||
})
|
||||
try {
|
||||
for (let ordinal = 1; ordinal <= 3; ordinal += 1) {
|
||||
await journal.appendItem(item(ordinal), body(`item-${ordinal}`), {
|
||||
fence: 1,
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
}
|
||||
const since = journal.readSince({ epoch: journal.epoch, sequence: 0 })
|
||||
if (!since.ok) {
|
||||
throw new Error(`expected rows, got reset ${since.reset}`)
|
||||
}
|
||||
expect(since.rows.every((row) => row.kind === 'outcome')).toBe(true)
|
||||
const withHole = since.rows.filter((row) => row.seq !== since.rows[1]?.seq)
|
||||
expect(
|
||||
projectJournalBatch({ rows: withHole, snapshot: journal.snapshot(), afterSequence: 0 })
|
||||
).toMatchObject({ ok: true })
|
||||
} finally {
|
||||
await journal.close()
|
||||
await closeTestJournalHostDatabase(root)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes touched items at their current reduced state, not as a delta', async () => {
|
||||
await appendItems(1)
|
||||
const cursor = fixture.journal.cursor()
|
||||
await fixture.journal.appendItem(item(1), body('revised'), {
|
||||
fence: 1,
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
const since = fixture.journal.readSince(cursor)
|
||||
if (!since.ok) {
|
||||
throw new Error(`expected rows, got reset ${since.reset}`)
|
||||
}
|
||||
const projected = projectJournalBatch({
|
||||
rows: since.rows,
|
||||
snapshot: fixture.journal.snapshot(),
|
||||
afterSequence: cursor.sequence
|
||||
})
|
||||
if (!projected.ok) {
|
||||
throw new Error(`expected a batch, got reset ${projected.reset}`)
|
||||
}
|
||||
expect(projected.batch.items).toHaveLength(1)
|
||||
expect(projected.batch.items[0]).toMatchObject({ body: body('revised'), revision: 2 })
|
||||
expect(projected.batch.cursor).toEqual(fixture.journal.cursor())
|
||||
})
|
||||
|
||||
it('lists a tombstoned item as removed', async () => {
|
||||
await appendItems(1)
|
||||
const cursor = fixture.journal.cursor()
|
||||
await fixture.journal.appendTombstone(item(1), { fence: 1 })
|
||||
const since = fixture.journal.readSince(cursor)
|
||||
if (!since.ok) {
|
||||
throw new Error(`expected rows, got reset ${since.reset}`)
|
||||
}
|
||||
const projected = projectJournalBatch({
|
||||
rows: since.rows,
|
||||
snapshot: fixture.journal.snapshot(),
|
||||
afterSequence: cursor.sequence
|
||||
})
|
||||
if (!projected.ok) {
|
||||
throw new Error(`expected a batch, got reset ${projected.reset}`)
|
||||
}
|
||||
expect(projected.batch.removedItemIds).toHaveLength(1)
|
||||
expect(projected.batch.items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('publishes a mismatched provider echo under its submission slot', async () => {
|
||||
const message: AgentJournalMessageItem = {
|
||||
kind: 'message',
|
||||
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
type AgentJournalItemBody
|
||||
} from '../../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemIdentity,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
import { createTrackedJournalOpener } from '../../../../agent-session-journal/store-lifecycle/journal-host-database-test-support'
|
||||
import type { AgentSessionJournal } from '../../../../agent-session-journal/journal-store'
|
||||
import { readAgentSessionHistory } from '../agent-session-history-page'
|
||||
import { codexProviderHandle } from '../../../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
}
|
||||
|
||||
const journals = createTrackedJournalOpener()
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
let epochs = 0
|
||||
let journal: AgentSessionJournal
|
||||
|
||||
function tick(): number {
|
||||
clock += 1
|
||||
return clock
|
||||
}
|
||||
|
||||
function item(ordinal: number): AgentJournalItemIdentity {
|
||||
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
|
||||
}
|
||||
|
||||
function body(text: string): AgentJournalItemBody {
|
||||
return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
async function appendItems(count: number, text: string): Promise<void> {
|
||||
for (let ordinal = 1; ordinal <= count; ordinal += 1) {
|
||||
await journal.appendItem(item(ordinal), body(`${text}-${ordinal}`), {
|
||||
fence: 1,
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-wire-history-'))
|
||||
clock = 1_000
|
||||
epochs = 0
|
||||
journal = await journals.open({
|
||||
identity: IDENTITY,
|
||||
stateDirectory: root,
|
||||
now: tick,
|
||||
mintEpoch: () => {
|
||||
epochs += 1
|
||||
return `epoch-${epochs}`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each([1, 100, 200])('serializes each of %i unchanged forward page items once', async (count) => {
|
||||
const cursor = journal.cursor()
|
||||
await appendItems(count, 'x'.repeat(8_000))
|
||||
const snapshot = journal.snapshot()
|
||||
const stringify = JSON.stringify
|
||||
// Method-shaped type: the JSON.stringify overloads split on replacer shape and reject a forwarded one.
|
||||
const forwardStringify: {
|
||||
stringify(value: unknown, replacer?: unknown, space?: unknown): string
|
||||
}['stringify'] = stringify
|
||||
let itemSerializations = 0
|
||||
JSON.stringify = (value: unknown, replacer?: unknown, space?: unknown): string => {
|
||||
if (value && typeof value === 'object' && 'itemId' in value && 'body' in value) {
|
||||
itemSerializations++
|
||||
}
|
||||
return forwardStringify(value, replacer, space)
|
||||
}
|
||||
try {
|
||||
const result = readAgentSessionHistory(
|
||||
journal,
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
direction: 'after',
|
||||
limit: count,
|
||||
cursor
|
||||
},
|
||||
{ snapshot }
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.page.items).toHaveLength(count)
|
||||
expect(itemSerializations).toBe(count)
|
||||
} finally {
|
||||
JSON.stringify = stringify
|
||||
}
|
||||
})
|
||||
-25
@@ -73,31 +73,6 @@ async function seedJournal(count: number) {
|
||||
}
|
||||
|
||||
describe('forward history SQL read budget', () => {
|
||||
it('keeps an exact final page final and preserves unlimited journal readers', async () => {
|
||||
const journal = await seedJournal(6)
|
||||
const cursor = { epoch: journal.epoch, sequence: 1 }
|
||||
expect(journal.readSince(cursor)).toMatchObject({ ok: true, rows: expect.any(Array) })
|
||||
const first = readAgentSessionHistory(journal, {
|
||||
sessionId: identity.sessionId,
|
||||
direction: 'after',
|
||||
cursor,
|
||||
limit: 3
|
||||
})
|
||||
expect(first).toMatchObject({ ok: true, page: { hasNewer: true } })
|
||||
if (!first.ok) {
|
||||
throw new Error('Expected first page')
|
||||
}
|
||||
const last = readAgentSessionHistory(journal, {
|
||||
sessionId: identity.sessionId,
|
||||
direction: 'after',
|
||||
cursor: first.page.window.nextCursor,
|
||||
limit: 3
|
||||
})
|
||||
expect(last).toMatchObject({ ok: true, page: { hasNewer: false } })
|
||||
const unlimited = journal.readSince(cursor)
|
||||
expect(unlimited.ok && unlimited.rows).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('reports a sequence gap when the next page reaches it', async () => {
|
||||
const journal = await seedJournal(6)
|
||||
const { db } = openTestJournalHostDatabase(root!)
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import {
|
||||
boundHistoryItemsByBytes,
|
||||
newestWholeSequenceGroups
|
||||
} from '../agent-session-history-page-bounds'
|
||||
|
||||
function item(index: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: String(index),
|
||||
revision: 1,
|
||||
sequence: index,
|
||||
observedAt: index,
|
||||
body: { kind: 'status', text: 'ok' }
|
||||
}
|
||||
}
|
||||
|
||||
it('retains entire boundary groups and preserves oversized first-group truncation', () => {
|
||||
const items = [item(1), { ...item(2), sequence: 1 }, item(3), { ...item(4), sequence: 3 }]
|
||||
expect(newestWholeSequenceGroups(items, 1)).toEqual(items.slice(2))
|
||||
expect(newestWholeSequenceGroups(items, 3)).toEqual(items.slice(2))
|
||||
expect(boundHistoryItemsByBytes(items, 'oldest', new Map(), 1)).toMatchObject({
|
||||
dropped: 2,
|
||||
items: [{ itemId: '1' }, { itemId: '2' }]
|
||||
})
|
||||
expect(boundHistoryItemsByBytes(items, 'newest', new Map(), 1)).toMatchObject({
|
||||
dropped: 2,
|
||||
items: [{ itemId: '3' }, { itemId: '4' }]
|
||||
})
|
||||
})
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readAgentSessionHistory } from '../agent-session-history-page'
|
||||
import { fixture, appendItems, item } from './agent-session-history-contract-test-fixture'
|
||||
|
||||
describe('readAgentSessionHistory', () => {
|
||||
it('carries tombstones in a forward catch-up page', async () => {
|
||||
await appendItems(1)
|
||||
const cursor = fixture.journal.cursor()
|
||||
await fixture.journal.appendTombstone(item(1), { fence: 1 })
|
||||
|
||||
const page = readAgentSessionHistory(fixture.journal, {
|
||||
sessionId: 'session-1',
|
||||
direction: 'after',
|
||||
cursor
|
||||
})
|
||||
if (!page.ok) {
|
||||
throw new Error(`expected a page, got reset ${page.reset}`)
|
||||
}
|
||||
expect(page.page.items).toHaveLength(0)
|
||||
expect(page.page.removedItemIds).toEqual(['codex:thread-1:turn-1:1'])
|
||||
})
|
||||
|
||||
it('reports a forward read with no cursor as cursor_ahead rather than serving the tail', async () => {
|
||||
await appendItems(1)
|
||||
expect(
|
||||
readAgentSessionHistory(fixture.journal, { sessionId: 'session-1', direction: 'after' })
|
||||
).toMatchObject({ ok: false, reset: 'cursor_ahead' })
|
||||
})
|
||||
|
||||
it('resets a cursor from a previous epoch', async () => {
|
||||
await appendItems(1)
|
||||
const stale = fixture.journal.cursor()
|
||||
await fixture.journal.rollEpoch('legacy_import', 2)
|
||||
for (const direction of ['before', 'after'] as const) {
|
||||
expect(
|
||||
readAgentSessionHistory(fixture.journal, {
|
||||
sessionId: 'session-1',
|
||||
direction,
|
||||
cursor: stale
|
||||
})
|
||||
).toMatchObject({ ok: false, reset: 'epoch_changed' })
|
||||
}
|
||||
})
|
||||
|
||||
it('resets a cursor ahead of the journal', async () => {
|
||||
await appendItems(1)
|
||||
const ahead = { epoch: fixture.journal.epoch, sequence: fixture.journal.cursor().sequence + 10 }
|
||||
expect(
|
||||
readAgentSessionHistory(fixture.journal, {
|
||||
sessionId: 'session-1',
|
||||
direction: 'before',
|
||||
cursor: ahead
|
||||
})
|
||||
).toMatchObject({ ok: false, reset: 'cursor_ahead' })
|
||||
})
|
||||
})
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { journalPayload } from '../../../../agent-session-journal/item-lifecycle/journal-payload-bounds'
|
||||
import { HISTORY_PAGE_CONTENT_BUDGET_BYTES } from '../agent-session-history-page-bounds'
|
||||
import { agentJournalItemForWire, shortenedAgentJournalItem } from '../agent-session-item-wire-form'
|
||||
|
||||
it('preserves the full previously readable desktop message range', () => {
|
||||
const item: AgentJournalRenderItem = {
|
||||
itemId: 'message',
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: `${'x'.repeat(1_200_000)}TAIL` }]
|
||||
}
|
||||
}
|
||||
const formed = agentJournalItemForWire(item)
|
||||
expect(formed.item).toBe(item)
|
||||
expect(formed.bytes).toBe(Buffer.byteLength(JSON.stringify(item), 'utf8'))
|
||||
})
|
||||
|
||||
it.each([73_728, HISTORY_PAGE_CONTENT_BUDGET_BYTES, 4 * 1024 * 1024])(
|
||||
'keeps tool identity and output when cardinality exceeds %i bytes',
|
||||
(budget) => {
|
||||
const item: AgentJournalRenderItem = {
|
||||
itemId: 'call',
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
callId: 'call',
|
||||
name: 'query',
|
||||
state: 'completed',
|
||||
input: { values: Array(Math.ceil(budget / 2) + 1_000).fill(0) },
|
||||
output: journalPayload('done')
|
||||
}
|
||||
}
|
||||
const sent = shortenedAgentJournalItem(item, budget)
|
||||
expect(sent).toMatchObject({
|
||||
itemId: 'call',
|
||||
shortened: true,
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'query',
|
||||
input: { truncated: true },
|
||||
output: { head: 'done' }
|
||||
}
|
||||
})
|
||||
expect(Buffer.byteLength(JSON.stringify(sent))).toBeLessThanOrEqual(budget)
|
||||
}
|
||||
)
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { jsonBytes, shortenJsonStringsToFit } from '../json-string-shortening'
|
||||
|
||||
it('cuts long strings to fit and marks them, never splitting a surrogate pair', () => {
|
||||
const value = { text: '😀'.repeat(2_000), other: 'x'.repeat(4_000) }
|
||||
const fitted = shortenJsonStringsToFit(value, 2_000)
|
||||
expect(fitted).not.toBeNull()
|
||||
expect(jsonBytes(fitted)).toBeLessThanOrEqual(2_000)
|
||||
expect(fitted?.text.endsWith('…')).toBe(true)
|
||||
// A lone high surrogate would serialize as an escape and render as a broken glyph.
|
||||
expect(fitted?.text).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/)
|
||||
})
|
||||
|
||||
it('leaves identifiers whole', () => {
|
||||
const id = 'i'.repeat(3_000)
|
||||
const fitted = shortenJsonStringsToFit({ id, itemId: id, text: 'x'.repeat(3_000) }, 7_000)
|
||||
expect(fitted).toMatchObject({ id, itemId: id })
|
||||
})
|
||||
|
||||
it('is null when even the shortest cut cannot fit', () => {
|
||||
const many = Array.from({ length: 1_000 }, () => 'x'.repeat(200))
|
||||
expect(shortenJsonStringsToFit(many, 1_000)).toBeNull()
|
||||
})
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_SESSION_STREAMED_TEXT_MAX_BYTES,
|
||||
AGENT_SESSION_STREAMED_TEXT_TRUNCATION_MARKER as marker,
|
||||
createAgentSessionDeltaCoalescer
|
||||
} from '../agent-session-delta-coalescer'
|
||||
|
||||
describe('streamed output overflow prefix', () => {
|
||||
it('encodes only the retained prefix when 64 KiB command deltas cross the default limit', () => {
|
||||
const delta = 'x'.repeat(64 * 1024)
|
||||
const emitted: string[] = []
|
||||
const instance = createAgentSessionDeltaCoalescer({
|
||||
emit: (_key, text) => emitted.push(text),
|
||||
schedule: () => () => {}
|
||||
})
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
instance.append('command', delta)
|
||||
}
|
||||
const from = vi.spyOn(Buffer, 'from')
|
||||
const concat = vi.spyOn(Buffer, 'concat')
|
||||
const allocate = vi.spyOn(Buffer, 'allocUnsafe')
|
||||
try {
|
||||
instance.append('command', delta)
|
||||
expect(concat).not.toHaveBeenCalled()
|
||||
expect(from).toHaveBeenCalledExactlyOnceWith(marker, 'utf8')
|
||||
expect(allocate).toHaveBeenCalledExactlyOnceWith(
|
||||
AGENT_SESSION_STREAMED_TEXT_MAX_BYTES - Buffer.byteLength(marker)
|
||||
)
|
||||
} finally {
|
||||
from.mockRestore()
|
||||
concat.mockRestore()
|
||||
allocate.mockRestore()
|
||||
}
|
||||
const text = 'x'.repeat(AGENT_SESSION_STREAMED_TEXT_MAX_BYTES - marker.length) + marker
|
||||
expect(instance.snapshot('command')).toEqual({
|
||||
text,
|
||||
observedBytes: 257 * delta.length,
|
||||
truncated: true
|
||||
})
|
||||
expect(instance.flushAll()).toBe(true)
|
||||
expect(emitted).toEqual([text])
|
||||
instance.dispose()
|
||||
})
|
||||
|
||||
it('leaves ordinary appends unencoded and joins split surrogates before overflow', () => {
|
||||
const instance = createAgentSessionDeltaCoalescer({
|
||||
emit: () => true,
|
||||
schedule: () => () => {}
|
||||
})
|
||||
const from = vi.spyOn(Buffer, 'from')
|
||||
const concat = vi.spyOn(Buffer, 'concat')
|
||||
const allocate = vi.spyOn(Buffer, 'allocUnsafe')
|
||||
try {
|
||||
instance.append('command', '\ud800')
|
||||
instance.append('command', '')
|
||||
instance.append('command', '\udc00')
|
||||
expect(from).not.toHaveBeenCalled()
|
||||
expect(concat).not.toHaveBeenCalled()
|
||||
expect(allocate).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
from.mockRestore()
|
||||
concat.mockRestore()
|
||||
allocate.mockRestore()
|
||||
}
|
||||
expect(instance.snapshot('command')).toEqual({
|
||||
text: '\ud800\udc00',
|
||||
observedBytes: 6,
|
||||
truncated: false
|
||||
})
|
||||
instance.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ cap: 0, chunks: ['abcdef'], text: '' },
|
||||
{ cap: 1, chunks: ['abcdef'], text: '\n' },
|
||||
{ cap: 33, chunks: ['x'.repeat(40)], text: marker.slice(0, 33) },
|
||||
{ cap: 34, chunks: ['x'.repeat(40)], text: marker },
|
||||
{ cap: 35, chunks: ['é', 'x'.repeat(40)], text: marker },
|
||||
{ cap: 36, chunks: ['a€', 'x'.repeat(40)], text: `a${marker}` },
|
||||
{ cap: 37, chunks: ['a€', 'x'.repeat(40)], text: `a${marker}` },
|
||||
{ cap: 38, chunks: ['a€', 'x'.repeat(40)], text: `a€${marker}` },
|
||||
{ cap: 40, chunks: ['\ud800', '', '\udc00', 'x'.repeat(40)], text: `\ufffd\ufffd${marker}` },
|
||||
{ cap: 39, chunks: ['a😀', 'x'.repeat(40)], text: `a😀${marker}` },
|
||||
{ cap: 38, chunks: ['a😀', 'x'.repeat(40)], text: `a${marker}` }
|
||||
])('preserves chunk encoding and UTF-8 clipping at a $cap byte cap', ({ cap, chunks, text }) => {
|
||||
const instance = createAgentSessionDeltaCoalescer({
|
||||
maxRetainedBytes: cap,
|
||||
emit: () => true,
|
||||
schedule: () => () => {}
|
||||
})
|
||||
for (const chunk of chunks) {
|
||||
instance.append('command', chunk)
|
||||
}
|
||||
expect(instance.snapshot('command')).toEqual({
|
||||
text,
|
||||
observedBytes: chunks.reduce((total, chunk) => total + Buffer.byteLength(chunk), 0),
|
||||
truncated: true
|
||||
})
|
||||
instance.dispose()
|
||||
})
|
||||
})
|
||||
-48
@@ -26,55 +26,7 @@ function manualClock() {
|
||||
}
|
||||
}
|
||||
|
||||
function coalescer(clock: ReturnType<typeof manualClock>, windowMs?: number) {
|
||||
const emitted: [string, string][] = []
|
||||
const instance = createAgentSessionDeltaCoalescer({
|
||||
emit: (key, text) => emitted.push([key, text]),
|
||||
schedule: clock.schedule,
|
||||
...(windowMs === undefined ? {} : { windowMs })
|
||||
})
|
||||
return { instance, emitted }
|
||||
}
|
||||
|
||||
describe('agent-session delta coalescer', () => {
|
||||
it('emits the accumulated snapshot again, not the increment, on the next window', () => {
|
||||
const clock = manualClock()
|
||||
const { instance, emitted } = coalescer(clock)
|
||||
|
||||
instance.append('item-1', 'he')
|
||||
clock.fire()
|
||||
instance.append('item-1', 'llo')
|
||||
clock.fire()
|
||||
|
||||
expect(emitted).toEqual([
|
||||
['item-1', 'he'],
|
||||
['item-1', 'hello']
|
||||
])
|
||||
})
|
||||
|
||||
it('retains dirty state and surfaces admission failure for retry', () => {
|
||||
const clock = manualClock()
|
||||
let reject = true
|
||||
const emitted: string[] = []
|
||||
const instance = createAgentSessionDeltaCoalescer({
|
||||
schedule: clock.schedule,
|
||||
emit: (_key, text) => {
|
||||
if (reject) {
|
||||
return false
|
||||
}
|
||||
emitted.push(text)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
instance.append('item-1', 'retry me')
|
||||
expect(instance.flush('item-1')).toBe(false)
|
||||
expect(emitted).toEqual([])
|
||||
reject = false
|
||||
expect(instance.flush('item-1')).toBe(true)
|
||||
expect(emitted).toEqual(['retry me'])
|
||||
})
|
||||
|
||||
it('does not evict buffered output when flushing the oldest stream is backpressured', () => {
|
||||
const clock = manualClock()
|
||||
let reject = true
|
||||
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createCodexStructuredItemStreams } from '../../../../../codex/structured/items/codex-structured-item-streams'
|
||||
import { createAgentSessionDeltaCoalescer } from '../agent-session-delta-coalescer'
|
||||
|
||||
describe('empty streamed deltas', () => {
|
||||
it('does not retain empty array slots through repeated Codex publications', () => {
|
||||
const prefix = 'empty-delta-retention-prefix'
|
||||
const streams = createCodexStructuredItemStreams({
|
||||
sink: { appendItem() {}, appendTombstone() {}, publish() {} },
|
||||
turnIdFor: () => 'turn',
|
||||
identityFor: () => ({ provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 }),
|
||||
attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }),
|
||||
schedule: () => () => {}
|
||||
})
|
||||
const append = (delta: string) =>
|
||||
streams.handle('thread', 'item/agentMessage/delta', { itemId: 'item', delta })
|
||||
append(prefix)
|
||||
try {
|
||||
for (let batch = 0; batch < 4; batch += 1) {
|
||||
for (let index = 0; index < 16_384; index += 1) {
|
||||
append('')
|
||||
}
|
||||
expect(streams.flush()).toBe(true)
|
||||
}
|
||||
const originalJoin = Array.prototype.join
|
||||
let retainedSlots = -1
|
||||
const spy = vi.spyOn(Array.prototype, 'join').mockImplementation(function (
|
||||
this: unknown[],
|
||||
separator
|
||||
) {
|
||||
// Byte counters cannot detect empty entries retained by the stream's chunk array.
|
||||
if (separator === '' && this[0] === prefix) {
|
||||
retainedSlots = this.length
|
||||
}
|
||||
return originalJoin.call(this, separator)
|
||||
})
|
||||
let snapshot: ReturnType<typeof streams.snapshot>
|
||||
try {
|
||||
snapshot = streams.snapshot('thread', 'item')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
expect(retainedSlots).toBe(1)
|
||||
expect(snapshot).toEqual({
|
||||
text: prefix,
|
||||
observedBytes: Buffer.byteLength(prefix),
|
||||
truncated: false
|
||||
})
|
||||
append('é')
|
||||
expect(streams.snapshot('thread', 'item')?.text).toBe(`${prefix}é`)
|
||||
streams.forget('thread', 'item')
|
||||
expect(streams.snapshot('thread', 'item')).toBeNull()
|
||||
} finally {
|
||||
streams.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves empty stream snapshots, scheduled publication and explicit flushes', () => {
|
||||
const pending = new Set<() => void>()
|
||||
const emitted: { key: string; text: string }[] = []
|
||||
const instance = createAgentSessionDeltaCoalescer({
|
||||
schedule: (run) => {
|
||||
pending.add(run)
|
||||
return () => {
|
||||
pending.delete(run)
|
||||
}
|
||||
},
|
||||
emit: (key, text) => emitted.push({ key, text })
|
||||
})
|
||||
try {
|
||||
expect(instance.append('empty', '')).toBe(true)
|
||||
expect(instance.snapshot('empty')).toEqual({
|
||||
text: '',
|
||||
observedBytes: 0,
|
||||
truncated: false
|
||||
})
|
||||
expect(pending.size).toBe(1)
|
||||
expect(emitted).toEqual([])
|
||||
expect(instance.flushAll()).toBe(true)
|
||||
expect(pending.size).toBe(0)
|
||||
expect(emitted).toEqual([{ key: 'empty', text: '' }])
|
||||
instance.append('empty', 'visible')
|
||||
instance.append('empty', '')
|
||||
expect(pending.size).toBe(1)
|
||||
expect(instance.flush('empty')).toBe(true)
|
||||
expect(emitted.at(-1)).toEqual({ key: 'empty', text: 'visible' })
|
||||
instance.append('empty', '')
|
||||
expect(instance.flushAll()).toBe(true)
|
||||
expect(emitted).toHaveLength(3)
|
||||
expect(emitted.at(-1)).toEqual({ key: 'empty', text: 'visible' })
|
||||
} finally {
|
||||
instance.dispose()
|
||||
}
|
||||
expect(pending.size).toBe(0)
|
||||
})
|
||||
|
||||
it('still refuses a new empty stream while the oldest output is backpressured', () => {
|
||||
let accepting = false
|
||||
const emitted: [string, string][] = []
|
||||
const instance = createAgentSessionDeltaCoalescer({
|
||||
maxStreams: 1,
|
||||
schedule: () => () => {},
|
||||
emit: (key, text) => {
|
||||
if (!accepting) {
|
||||
return false
|
||||
}
|
||||
emitted.push([key, text])
|
||||
return true
|
||||
}
|
||||
})
|
||||
try {
|
||||
instance.append('first', 'preserved')
|
||||
expect(instance.append('second', '')).toBe(false)
|
||||
expect(instance.snapshot('second')).toBeNull()
|
||||
expect(instance.snapshot('first')?.text).toBe('preserved')
|
||||
accepting = true
|
||||
expect(instance.append('second', '')).toBe(true)
|
||||
expect(instance.snapshot('first')).toBeNull()
|
||||
expect(instance.snapshot('second')?.text).toBe('')
|
||||
expect(instance.flushAll()).toBe(true)
|
||||
expect(emitted).toEqual([
|
||||
['first', 'preserved'],
|
||||
['second', '']
|
||||
])
|
||||
} finally {
|
||||
instance.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
AgentSessionSlashCommand,
|
||||
AgentSessionSubscribeEvent
|
||||
} from '../../../../../../shared/agent-session/agent-session-wire'
|
||||
import type { AgentSessionBackgroundTaskState } from '../../../../../../shared/agent-session/agent-session-background-task-wire'
|
||||
import { createStructuredAgentSessionEventCoalescer } from '../../../../../../shared/agent-session/structured-agent-session-coalescer'
|
||||
import {
|
||||
EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
reduceStructuredAgentSession
|
||||
} from '../../../../../../shared/agent-session/structured-agent-session-reducer'
|
||||
import { createTrackedJournalOpener } from '../../../../agent-session-journal/store-lifecycle/journal-host-database-test-support'
|
||||
import { AgentSessionSubscribers } from '../structured-agent-session-subscribers'
|
||||
import { identityFor } from '../../../../../claude/structured/claude-structured-session-test-support'
|
||||
|
||||
it('delivers catalog changes through existing frames without resending them on ordinary output', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-command-publication-'))
|
||||
const journals = createTrackedJournalOpener()
|
||||
const events: AgentSessionSubscribeEvent[] = []
|
||||
let state = EMPTY_STRUCTURED_AGENT_SESSION
|
||||
const coalescer = createStructuredAgentSessionEventCoalescer((event) => {
|
||||
events.push(event)
|
||||
state = reduceStructuredAgentSession(state, { type: 'event', event })
|
||||
})
|
||||
try {
|
||||
const journal = await journals.open({ identity: identityFor(), stateDirectory: root })
|
||||
const sessionId = identityFor().sessionId
|
||||
let commands: AgentSessionSlashCommand[] | undefined = [
|
||||
{ name: 'loaded', kind: 'command', kindUnspecified: true }
|
||||
]
|
||||
let roster: AgentSessionBackgroundTaskState | null = null
|
||||
const subscribers = new AgentSessionSubscribers({
|
||||
readCommands: () => commands,
|
||||
readBackgroundTasks: () => roster
|
||||
})
|
||||
const republishRoster = (): void => {
|
||||
roster = roster ? null : { state: 'monitoring' }
|
||||
subscribers.republishBackgroundTasks(sessionId, 7)
|
||||
}
|
||||
const close = subscribers.open({
|
||||
id: 'one',
|
||||
sessionId,
|
||||
journal,
|
||||
fence: 7,
|
||||
emit: coalescer.push
|
||||
})
|
||||
expect(state.commands).toEqual(commands)
|
||||
for (let i = 0; i < 25; i++) {
|
||||
republishRoster()
|
||||
}
|
||||
coalescer.flush()
|
||||
expect(events.filter((event) => 'commands' in event)).toHaveLength(1)
|
||||
commands = []
|
||||
subscribers.publish(sessionId, journal)
|
||||
republishRoster()
|
||||
coalescer.flush()
|
||||
expect(state.commands).toEqual([])
|
||||
expect(events.filter((event) => 'commands' in event)).toHaveLength(2)
|
||||
commands = undefined
|
||||
subscribers.publish(sessionId, journal)
|
||||
coalescer.flush()
|
||||
expect(state.commands).toBeNull()
|
||||
close()
|
||||
commands = [{ name: 'reconnected', kind: 'skill' }]
|
||||
subscribers.open({
|
||||
id: 'two',
|
||||
sessionId,
|
||||
journal,
|
||||
cursor: journal.cursor(),
|
||||
fence: 8,
|
||||
emit: coalescer.push
|
||||
})
|
||||
coalescer.flush()
|
||||
expect(state.commands).toEqual(commands)
|
||||
subscribers.snapshot(sessionId, journal, 8)
|
||||
expect(state.commands).toEqual(commands)
|
||||
commands = undefined
|
||||
subscribers.open({
|
||||
id: 'three',
|
||||
sessionId,
|
||||
journal,
|
||||
cursor: journal.cursor(),
|
||||
fence: 9,
|
||||
emit: coalescer.push
|
||||
})
|
||||
coalescer.flush()
|
||||
expect(state.commands).toBeNull()
|
||||
} finally {
|
||||
coalescer.dispose()
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type {
|
||||
AgentSessionStatusEvent,
|
||||
AgentSessionSubscribeEvent
|
||||
} from '../../../../../../shared/agent-session/agent-session-wire'
|
||||
import type { AgentSessionBackgroundTaskState } from '../../../../../../shared/agent-session/agent-session-background-task-wire'
|
||||
import { StructuredAgentSessionStatusFeed } from '../../../observations/structured-agent-session-status-feed'
|
||||
import { AgentSessionSubscribers } from '../structured-agent-session-subscribers'
|
||||
import { createStructuredAgentSessionLogger } from '../../../host/structured-agent-session-logger'
|
||||
import {
|
||||
claudeProviderHandle,
|
||||
codexProviderHandle
|
||||
} from '../../../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
|
||||
|
||||
import {
|
||||
fixture,
|
||||
SESSION,
|
||||
journals
|
||||
} from './structured-agent-session-subscriber-contract-test-fixture'
|
||||
|
||||
it('settles a session nobody is reading, from running to idle', async () => {
|
||||
// Never open a subscriber: status must publish independently of transcript readers.
|
||||
const journal = await journals.open({
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
},
|
||||
stateDirectory: join(fixture.root, 'unread-journal')
|
||||
})
|
||||
const statusFeed = new StructuredAgentSessionStatusFeed({
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
sessions: new Map([
|
||||
[
|
||||
SESSION,
|
||||
{
|
||||
journal,
|
||||
params: {
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
},
|
||||
provider: 'codex'
|
||||
}
|
||||
}
|
||||
]
|
||||
]),
|
||||
getConversation: () => null,
|
||||
now: () => 1_000
|
||||
})
|
||||
const subscribers = new AgentSessionSubscribers({
|
||||
onJournalPublished: (sessionId, published) => statusFeed.publish(sessionId, published)
|
||||
})
|
||||
const statuses: AgentSessionStatusEvent[] = []
|
||||
statusFeed.subscribe({ id: 'session-list', emit: (event) => statuses.push(event) })
|
||||
const turn = { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } as const
|
||||
|
||||
await journal.appendItem(
|
||||
{ ...turn, ordinal: 1 },
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await journal.appendItem(
|
||||
turn,
|
||||
{ kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
subscribers.publish(SESSION, journal)
|
||||
|
||||
expect(statuses.at(-1)).toEqual({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({ status: 'working', latestPrompt: 'write a poem' })
|
||||
})
|
||||
|
||||
await journal.appendTombstone(turn, { fence: 1 })
|
||||
subscribers.publish(SESSION, journal)
|
||||
|
||||
expect(statuses.at(-1)).toEqual({
|
||||
type: 'status',
|
||||
session: expect.objectContaining({ status: 'idle' })
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes background lifecycle without advancing the journal and carries its fence forward', async () => {
|
||||
const journal = await journals.open({
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'claude',
|
||||
providerHandle: claudeProviderHandle('provider-1', null)
|
||||
},
|
||||
stateDirectory: join(fixture.root, 'background-journal')
|
||||
})
|
||||
let roster: AgentSessionBackgroundTaskState | null = null
|
||||
const subscribers = new AgentSessionSubscribers({ readBackgroundTasks: () => roster })
|
||||
const events: AgentSessionSubscribeEvent[] = []
|
||||
subscribers.open({
|
||||
id: 'subscriber-1',
|
||||
sessionId: SESSION,
|
||||
journal,
|
||||
fence: 1,
|
||||
emit: (event) => events.push(event)
|
||||
})
|
||||
const cursor = journal.cursor()
|
||||
|
||||
const backgroundTasks: AgentSessionBackgroundTaskState = {
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-1', kind: 'command', description: 'run the build' }]
|
||||
}
|
||||
roster = backgroundTasks
|
||||
subscribers.republishBackgroundTasks(SESSION, 2)
|
||||
|
||||
expect(journal.cursor()).toEqual(cursor)
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: 'batch',
|
||||
sessionId: SESSION,
|
||||
batch: { cursor, items: [], removedItemIds: [], submissions: [] },
|
||||
fence: 2,
|
||||
hostNow: expect.any(Number),
|
||||
backgroundTasks
|
||||
})
|
||||
|
||||
await journal.appendItem(
|
||||
{ provider: 'orca', clientMessageId: 'after-background-fence' },
|
||||
{ kind: 'status', text: 'After background state' },
|
||||
{ fence: 2, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
subscribers.publish(SESSION, journal)
|
||||
|
||||
expect(events.at(-1)).toMatchObject({ type: 'batch', fence: 2 })
|
||||
})
|
||||
|
||||
it('publishes latest turn activity without advancing or adding journal rows', async () => {
|
||||
const journal = await journals.open({
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
},
|
||||
stateDirectory: join(fixture.root, 'activity-journal')
|
||||
})
|
||||
const subscribers = new AgentSessionSubscribers()
|
||||
const events: AgentSessionSubscribeEvent[] = []
|
||||
subscribers.open({
|
||||
id: 'subscriber-1',
|
||||
sessionId: SESSION,
|
||||
journal,
|
||||
fence: 1,
|
||||
emit: (event) => events.push(event)
|
||||
})
|
||||
const cursor = journal.cursor()
|
||||
|
||||
subscribers.publish(SESSION, journal, {
|
||||
turnId: 'turn-1',
|
||||
text: 'Inspecting the session wire'
|
||||
})
|
||||
|
||||
expect(journal.cursor()).toEqual(cursor)
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: 'batch',
|
||||
sessionId: SESSION,
|
||||
batch: { cursor, items: [], removedItemIds: [], submissions: [] },
|
||||
fence: 1,
|
||||
hostNow: expect.any(Number),
|
||||
activity: { turnId: 'turn-1', text: 'Inspecting the session wire' }
|
||||
})
|
||||
|
||||
subscribers.close(SESSION, 'subscriber-1')
|
||||
subscribers.publish(SESSION, journal, null)
|
||||
subscribers.open({
|
||||
id: 'reconnected',
|
||||
sessionId: SESSION,
|
||||
journal,
|
||||
fence: 1,
|
||||
cursor,
|
||||
emit: (event) => events.push(event)
|
||||
})
|
||||
expect(journal.cursor()).toEqual(cursor)
|
||||
expect(events.at(-1)).toMatchObject({ activity: null })
|
||||
})
|
||||
-20
@@ -113,26 +113,6 @@ function deps(
|
||||
}
|
||||
|
||||
describe('structured session recovery resolution', () => {
|
||||
it('releases an ownerless reservation: nothing it recorded can be holding it', async () => {
|
||||
const store = await openStore()
|
||||
await reserve(store)
|
||||
await latch(store)
|
||||
|
||||
const result = await resolveStructuredSessionRecovery(
|
||||
deps(store, () => ({ outcome: 'indeterminate', reason: 'no scan' })),
|
||||
SESSION
|
||||
)
|
||||
|
||||
expect(result).toBe('resolved')
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
claimStatus: 'released',
|
||||
handoffStage: null,
|
||||
runtimeFence: 2,
|
||||
reservedSpawnToken: null,
|
||||
deathEvidence: null
|
||||
})
|
||||
})
|
||||
|
||||
it('releases an owner whose identity cannot be verified, and signals nothing', async () => {
|
||||
const store = await openStore()
|
||||
await liveOwner(store)
|
||||
|
||||
-37
@@ -14,43 +14,6 @@ type Handover = { dispatchState: string; reason?: string | null } | undefined
|
||||
|
||||
const LIMIT = 3
|
||||
|
||||
it('publishes queued chats separately from the chat holding the start slot', async () => {
|
||||
const first = Promise.withResolvers<void>()
|
||||
const phases = new Map<string, string>()
|
||||
const action = resumeStructuredAgentSessionsFromRestart(
|
||||
{
|
||||
admission: new StructuredAgentSessionResumeAdmission(1),
|
||||
onPhase: (sessionId, phase) => phases.set(sessionId, phase),
|
||||
consumeMarker: async () => true,
|
||||
resume: async (sessionId) => {
|
||||
if (sessionId === 'a') {
|
||||
await first.promise
|
||||
}
|
||||
}
|
||||
},
|
||||
['a', 'b'].map((sessionId) => ({
|
||||
sessionId,
|
||||
workspaceId: 'folder',
|
||||
executionHostId: 'local',
|
||||
workspaceKind: 'folder',
|
||||
agent: 'codex',
|
||||
work: { kind: 'turn', id: 'turn' },
|
||||
trigger: 'quit',
|
||||
recordedAt: 1,
|
||||
latestPrompt: ''
|
||||
})),
|
||||
'modal'
|
||||
)
|
||||
try {
|
||||
await vi.waitFor(() => expect(phases.get('a')).toBe('starting'))
|
||||
expect(phases.get('b')).toBe('queued')
|
||||
} finally {
|
||||
first.resolve()
|
||||
await action
|
||||
}
|
||||
expect(phases.get('b')).toBe('starting')
|
||||
})
|
||||
|
||||
it('holds each slot from accept until handover or rejection, and frees it when the wait ends (P2-27)', async () => {
|
||||
const handovers = new Map<string, PromiseWithResolvers<Handover>>()
|
||||
let waiting = 0
|
||||
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { structuredAgentSessionResumableSet } from '../structured-agent-session-restart-resume-set'
|
||||
import {
|
||||
structuredAgentSessionWorkingAtStop,
|
||||
structuredAgentSessionWorkInFlight
|
||||
} from '../../termination/structured-agent-session-working-at-teardown'
|
||||
import {
|
||||
record,
|
||||
marker,
|
||||
journal,
|
||||
turnItem,
|
||||
submission,
|
||||
NOW,
|
||||
SESSION,
|
||||
TEARDOWN_CURRENT
|
||||
} from './structured-agent-session-restart-resume-test-harness'
|
||||
import type { AgentJournalRenderItem } from '../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
|
||||
const boundary = { operationId: 'clear', afterFence: 1, clearedAt: NOW }
|
||||
const clear: AgentJournalRenderItem = {
|
||||
itemId: 'clear',
|
||||
sequence: 5,
|
||||
revision: 1,
|
||||
observedAt: NOW,
|
||||
body: { kind: 'status', text: 'Context cleared', contextClear: boundary }
|
||||
}
|
||||
const value = (): ReturnType<typeof record> & { providerContextBoundary: typeof boundary } => ({
|
||||
...record(),
|
||||
providerHandleChain: [],
|
||||
providerContextBoundary: boundary
|
||||
})
|
||||
const set = (m = marker(), r = value()) =>
|
||||
structuredAgentSessionResumableSet({
|
||||
markers: [m],
|
||||
getRecord: () => r,
|
||||
supportsRecord: () => true,
|
||||
latestPrompt: () => 'prior prompt',
|
||||
movedOn: () => false,
|
||||
savedByNewerOrca: () => false
|
||||
})
|
||||
|
||||
describe('restart offers after context clear', () => {
|
||||
it('retires the old-context offer before any new provider handle exists', () => {
|
||||
expect(set()).toEqual({ candidates: [], superseded: [marker()] })
|
||||
expect(set({ ...marker(), contextClearOperationId: 'previous-clear' })).toEqual({
|
||||
candidates: [],
|
||||
superseded: [{ ...marker(), contextClearOperationId: 'previous-clear' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat an old unknown send as new-context work during teardown', () => {
|
||||
const unknown = { ...submission('old-send', 'unknown'), acceptedSequence: 2 }
|
||||
expect(
|
||||
structuredAgentSessionWorkInFlight([turnItem('old', 'running'), clear], [unknown])
|
||||
).toBeNull()
|
||||
expect(
|
||||
structuredAgentSessionWorkingAtStop({
|
||||
sessionId: SESSION,
|
||||
session: {
|
||||
journal: journal([turnItem('old', 'running'), clear], [unknown]),
|
||||
child: { fence: 2, phase: 'ready' }
|
||||
},
|
||||
getRecord: value,
|
||||
childWorkOpen: () => false,
|
||||
childWork: () => undefined,
|
||||
trigger: 'quit',
|
||||
teardownId: TEARDOWN_CURRENT,
|
||||
now: NOW
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('stamps a current-context offer and keeps it eligible after restart', () => {
|
||||
const proved = { ...record().providerHandleChain[0], linkId: 'new', mintedAtFence: 2 }
|
||||
const currentRecord = {
|
||||
...value(),
|
||||
providerHandleChain: [proved]
|
||||
}
|
||||
const turn = { ...turnItem('new-turn', 'running'), sequence: 6 }
|
||||
const offer = structuredAgentSessionWorkingAtStop({
|
||||
sessionId: SESSION,
|
||||
session: { journal: journal([clear, turn]), child: { fence: 2, phase: 'ready' } },
|
||||
getRecord: () => currentRecord,
|
||||
childWorkOpen: () => false,
|
||||
childWork: () => undefined,
|
||||
trigger: 'quit',
|
||||
teardownId: TEARDOWN_CURRENT,
|
||||
now: NOW
|
||||
})!
|
||||
expect(offer.contextClearOperationId).toBe('clear')
|
||||
expect(set(offer, currentRecord).candidates).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { marker, SESSION } from './structured-agent-session-restart-resume-test-harness'
|
||||
import {
|
||||
AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE,
|
||||
AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE,
|
||||
AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE
|
||||
} from '../../../../../shared/agent-session/lifecycle/agent-session-restart-continuation'
|
||||
import {
|
||||
RestartContinuationSupersededError,
|
||||
startStructuredAgentSessionContinuation,
|
||||
type StructuredAgentSessionContinuationDeps
|
||||
} from '../structured-agent-session-restart-continuation'
|
||||
import { createStructuredAgentSessionLogger } from '../../host/structured-agent-session-logger'
|
||||
|
||||
async function continueStructuredAgentSessionAfterRestart(
|
||||
...args: Parameters<typeof startStructuredAgentSessionContinuation>
|
||||
) {
|
||||
const started = await startStructuredAgentSessionContinuation(...args)
|
||||
return 'done' in started ? started.done : started.verdict()
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
settledDispatch: 'accepted' | 'pending' | 'unknown' | 'rejected',
|
||||
handedOver: 'pending' | 'rejected' | undefined = 'pending'
|
||||
): StructuredAgentSessionContinuationDeps & {
|
||||
note: ReturnType<typeof vi.fn>
|
||||
send: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
return {
|
||||
currentFence: () => 1,
|
||||
send: vi.fn(async () => ({
|
||||
ok: true,
|
||||
value: { submission: { dispatchState: 'pending' } }
|
||||
})),
|
||||
awaitSettlement: vi.fn(async () => ({
|
||||
dispatchState: settledDispatch,
|
||||
reason: settledDispatch === 'rejected' ? 'provider_refused' : null
|
||||
})),
|
||||
awaitHandedOver: vi.fn(async () =>
|
||||
handedOver
|
||||
? {
|
||||
dispatchState: handedOver,
|
||||
reason: handedOver === 'rejected' ? 'Codex could not start.' : null
|
||||
}
|
||||
: undefined
|
||||
),
|
||||
note: vi.fn(async () => undefined),
|
||||
logger: createStructuredAgentSessionLogger()
|
||||
}
|
||||
}
|
||||
|
||||
const UNCONFIRMED = [SESSION, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, 'warning']
|
||||
const REFUSED = [SESSION, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, 'error']
|
||||
const NOT_CONNECTED = [SESSION, AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, 'error']
|
||||
|
||||
it.each([
|
||||
['pending', { sessionId: SESSION, outcome: 'pending' }, UNCONFIRMED],
|
||||
['unknown', { sessionId: SESSION, outcome: 'unknown' }, UNCONFIRMED],
|
||||
['rejected', { sessionId: SESSION, outcome: 'refused', reason: 'provider_refused' }, REFUSED]
|
||||
] as const)(
|
||||
'preserves a %s settlement and notes it in the chat instead of the success note',
|
||||
async (settled, expected, note) => {
|
||||
const deps = dependencies(settled)
|
||||
|
||||
await expect(
|
||||
continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1')
|
||||
).resolves.toEqual(expected)
|
||||
expect(deps.note).toHaveBeenCalledExactlyOnceWith(...note)
|
||||
}
|
||||
)
|
||||
|
||||
it("writes nothing in the chat when the user's own message came first, and still refuses", async () => {
|
||||
const deps = dependencies('accepted')
|
||||
deps.send.mockRejectedValue(new RestartContinuationSupersededError())
|
||||
|
||||
await expect(
|
||||
continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1')
|
||||
).rejects.toBeInstanceOf(RestartContinuationSupersededError)
|
||||
expect(deps.note).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Ownership refusal also blocks personal sends; do not advise sending one.
|
||||
it.each([
|
||||
['agent_session_conflict', NOT_CONNECTED],
|
||||
['agent_session_operation_invalid', REFUSED]
|
||||
] as const)('reports a %s send refusal without waiting for settlement', async (code, note) => {
|
||||
const deps = dependencies('accepted')
|
||||
deps.send.mockResolvedValue({ ok: false, refusal: { code } })
|
||||
|
||||
await expect(
|
||||
continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1')
|
||||
).resolves.toEqual({
|
||||
sessionId: SESSION,
|
||||
outcome: 'refused',
|
||||
reason: code,
|
||||
refusal: { code }
|
||||
})
|
||||
expect(deps.awaitSettlement).not.toHaveBeenCalled()
|
||||
expect(deps.note).toHaveBeenCalledExactlyOnceWith(...note)
|
||||
})
|
||||
|
||||
it('reports a newer-Orca send refusal with its reason', async () => {
|
||||
const deps = dependencies('accepted')
|
||||
const refusal = {
|
||||
code: 'agent_session_journal_unreadable',
|
||||
details: { reason: 'journalWrittenByNewerOrca' }
|
||||
} as const
|
||||
deps.send.mockResolvedValue({ ok: false, refusal: { ...refusal, message: 'Update Orca.' } })
|
||||
|
||||
await expect(
|
||||
continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1')
|
||||
).resolves.toEqual({
|
||||
sessionId: SESSION,
|
||||
outcome: 'refused',
|
||||
reason: 'agent_session_journal_unreadable',
|
||||
refusal
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an unattached chat without sending', async () => {
|
||||
const deps = dependencies('accepted')
|
||||
deps.currentFence = () => null
|
||||
|
||||
await expect(
|
||||
continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1')
|
||||
).resolves.toEqual({
|
||||
sessionId: SESSION,
|
||||
outcome: 'refused',
|
||||
reason: 'agent_session_not_attached'
|
||||
})
|
||||
expect(deps.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a continuation rejected at handover without waiting for the provider', async () => {
|
||||
const deps = dependencies('accepted', 'rejected')
|
||||
|
||||
await expect(
|
||||
continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1')
|
||||
).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', reason: 'Codex could not start.' })
|
||||
expect(deps.awaitSettlement).not.toHaveBeenCalled()
|
||||
expect(deps.note).toHaveBeenCalledExactlyOnceWith(...REFUSED)
|
||||
})
|
||||
|
||||
it('returns at handover and leaves the provider verdict to be awaited', async () => {
|
||||
const deps = dependencies('accepted')
|
||||
|
||||
const started = await startStructuredAgentSessionContinuation(
|
||||
deps,
|
||||
SESSION,
|
||||
marker(),
|
||||
'operation-1'
|
||||
)
|
||||
|
||||
expect(deps.awaitHandedOver).toHaveBeenCalledOnce()
|
||||
expect(deps.awaitSettlement).not.toHaveBeenCalled()
|
||||
if (!('verdict' in started)) {
|
||||
throw new Error('expected a handed-over continuation')
|
||||
}
|
||||
await expect(started.verdict()).resolves.toEqual({ sessionId: SESSION, outcome: 'continued' })
|
||||
expect(deps.note).toHaveBeenCalledOnce()
|
||||
})
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { structuredAgentSessionResumableSet } from '../structured-agent-session-restart-resume-set'
|
||||
import { structuredAgentSessionWorkingAtStop } from '../../termination/structured-agent-session-working-at-teardown'
|
||||
import {
|
||||
childRecord,
|
||||
CLAUDE_ROOT,
|
||||
claudeRecord,
|
||||
journal,
|
||||
TEARDOWN_CURRENT,
|
||||
marker,
|
||||
NOW,
|
||||
record,
|
||||
resumableSet,
|
||||
SESSION,
|
||||
submission,
|
||||
turnItem
|
||||
} from './structured-agent-session-restart-resume-test-harness'
|
||||
|
||||
type StopInput = Parameters<typeof structuredAgentSessionWorkingAtStop>[0]
|
||||
|
||||
function markersAtTeardown(
|
||||
input: Omit<StopInput, 'sessionId' | 'session'> & {
|
||||
sessions: ReadonlyMap<string, NonNullable<StopInput['session']>>
|
||||
}
|
||||
) {
|
||||
return [...input.sessions].flatMap(([sessionId, session]) => {
|
||||
const recorded = structuredAgentSessionWorkingAtStop({ ...input, sessionId, session })
|
||||
return recorded ? [recorded] : []
|
||||
})
|
||||
}
|
||||
|
||||
describe('deriving what was working at teardown', () => {
|
||||
// Persisted running rows prove no live execution; only this runtime’s child can justify a witness.
|
||||
it('marks nothing for a stale running row this host was not executing', () => {
|
||||
expect(
|
||||
markersAtTeardown({
|
||||
sessions: new Map([
|
||||
[SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: null }]
|
||||
]),
|
||||
getRecord: () => record(),
|
||||
childWorkOpen: () => false,
|
||||
childWork: () => undefined,
|
||||
trigger: 'quit',
|
||||
teardownId: TEARDOWN_CURRENT,
|
||||
now: NOW
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
// Capture lost prompts with the same work snapshot so blocked offers name what they lost.
|
||||
|
||||
it('marks nothing for a settled lead whose tasks have all finished', () => {
|
||||
expect(
|
||||
markersAtTeardown({
|
||||
sessions: new Map([
|
||||
[
|
||||
SESSION,
|
||||
{
|
||||
journal: journal([turnItem('turn-1', 'completed')]),
|
||||
child: { fence: 1, phase: 'ready' as const }
|
||||
}
|
||||
]
|
||||
]),
|
||||
getRecord: () => record(),
|
||||
childWorkOpen: () => false,
|
||||
childWork: () => [
|
||||
childRecord({ id: 'task-a', kind: 'agent', state: 'done' }),
|
||||
childRecord({ id: 'task-b', kind: 'command', state: 'idle' })
|
||||
],
|
||||
trigger: 'quit',
|
||||
teardownId: TEARDOWN_CURRENT,
|
||||
now: NOW
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('marks nothing for a session that never proved a provider cursor', () => {
|
||||
expect(
|
||||
markersAtTeardown({
|
||||
sessions: new Map([
|
||||
[
|
||||
SESSION,
|
||||
{
|
||||
journal: journal([turnItem('turn-1', 'running')]),
|
||||
child: { fence: 1, phase: 'ready' as const }
|
||||
}
|
||||
]
|
||||
]),
|
||||
getRecord: () => record({ chain: [] }),
|
||||
childWorkOpen: () => false,
|
||||
childWork: () => undefined,
|
||||
trigger: 'quit',
|
||||
teardownId: TEARDOWN_CURRENT,
|
||||
now: NOW
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
// Claude may echo user input long after dispatch; mark the send before any turn ID exists.
|
||||
|
||||
it('marks nothing for a session whose only work is a message still queued', () => {
|
||||
const queued = { ...submission('msg-1', 'pending'), handoverRecorded: true as const }
|
||||
expect(
|
||||
markersAtTeardown({
|
||||
sessions: new Map([
|
||||
[
|
||||
SESSION,
|
||||
{
|
||||
journal: journal([turnItem('turn-0', 'completed')], [queued]),
|
||||
child: { fence: 1, phase: 'ready' as const }
|
||||
}
|
||||
]
|
||||
]),
|
||||
getRecord: () => claudeRecord(null),
|
||||
childWorkOpen: () => false,
|
||||
childWork: () => undefined,
|
||||
trigger: 'quit',
|
||||
teardownId: TEARDOWN_CURRENT,
|
||||
now: NOW
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('marks a handed-over message over a newer queued one, and a turn a queued one waits behind', () => {
|
||||
const handedOver = {
|
||||
...submission('msg-1', 'pending'),
|
||||
handoverRecorded: true as const,
|
||||
handedOverAt: NOW
|
||||
}
|
||||
const queued = { ...submission('msg-2', 'pending'), handoverRecorded: true as const }
|
||||
const markers = markersAtTeardown({
|
||||
sessions: new Map([
|
||||
[
|
||||
SESSION,
|
||||
{
|
||||
journal: journal([turnItem('turn-0', 'completed')], [handedOver, queued]),
|
||||
child: { fence: 1, phase: 'ready' as const }
|
||||
}
|
||||
],
|
||||
[
|
||||
'session-running',
|
||||
{
|
||||
journal: journal([turnItem('turn-1', 'running')], [queued]),
|
||||
child: { fence: 1, phase: 'ready' as const }
|
||||
}
|
||||
]
|
||||
]),
|
||||
getRecord: () => claudeRecord(null),
|
||||
childWorkOpen: () => false,
|
||||
childWork: () => undefined,
|
||||
trigger: 'quit',
|
||||
teardownId: TEARDOWN_CURRENT,
|
||||
now: NOW
|
||||
})
|
||||
|
||||
expect(markers.map((entry) => entry.work)).toEqual([
|
||||
{ kind: 'submission', id: 'msg-1' },
|
||||
{ kind: 'turn', id: 'turn-1' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the resumable set', () => {
|
||||
// Same-teardown close advances Claude’s leaf; comparing full keys would permanently reject continuity.
|
||||
it('still offers a Claude session whose leaf advanced after the marker was written', () => {
|
||||
const { candidates } = structuredAgentSessionResumableSet({
|
||||
markers: [marker({ providerHandleRoot: CLAUDE_ROOT })],
|
||||
getRecord: () => claudeRecord('5aed93d6-advanced-leaf'),
|
||||
supportsRecord: () => true,
|
||||
latestPrompt: () => '',
|
||||
movedOn: () => false,
|
||||
savedByNewerOrca: () => false
|
||||
})
|
||||
|
||||
expect(candidates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('withdraws and reports for deletion a Claude session that forked to a different root', () => {
|
||||
const forked = marker({ providerHandleRoot: CLAUDE_ROOT })
|
||||
const set = structuredAgentSessionResumableSet({
|
||||
markers: [forked],
|
||||
getRecord: () => claudeRecord(null, 'prov-session-2'),
|
||||
supportsRecord: () => true,
|
||||
latestPrompt: () => '',
|
||||
movedOn: () => false,
|
||||
savedByNewerOrca: () => false
|
||||
})
|
||||
|
||||
expect(set.candidates).toEqual([])
|
||||
expect(set.superseded).toEqual([forked])
|
||||
})
|
||||
|
||||
it("neither offers nor spends a newer Orca's chat, and offers it once this build can open it", () => {
|
||||
const offer = marker()
|
||||
expect(resumableSet({ markers: [offer], savedByNewerOrca: [SESSION] })).toEqual({
|
||||
candidates: [],
|
||||
superseded: []
|
||||
})
|
||||
expect(resumableSet({ markers: [offer] }).candidates).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Structural refusal must preserve a durable offer the user still owns.
|
||||
it('does not report a structurally refused marker for deletion', () => {
|
||||
expect(resumableSet({ markers: [marker()], chain: [] }).superseded).toEqual([])
|
||||
})
|
||||
|
||||
// Provider-authored restore rows cannot overturn teardown’s observed work.
|
||||
})
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
import { foundAgentSessionRecord } from '../../../../runtime/agent-session/records/agent-session-record-founding'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../../../../shared/agent-session/lifecycle/agent-session-record'
|
||||
import type { StructuredAgentSessionAttachContext } from '../../acquisition/structured-agent-session-attach-context'
|
||||
import { startStructuredAgentSessionAgentForOperation } from '../structured-agent-session-agent-start'
|
||||
import { StructuredAgentSessionAcquireAborts } from '../structured-agent-session-acquire-aborts'
|
||||
import { StructuredAgentSessionStartupAttempts } from '../structured-agent-session-startup-attempt'
|
||||
import { StructuredAgentSessionStartFlights } from '../structured-agent-session-start-flight'
|
||||
import '../../options/structured-agent-session-options-read'
|
||||
import { recordStructuredAgentSessionOptionIntent } from '../../options/structured-agent-session-option-intent'
|
||||
import { claudeAndCodexAgents } from '../../providers/testing/structured-agent-session-adapter-router-test-support'
|
||||
import { recordingStructuredAgentSessionLogger } from '../../host/testing/structured-agent-session-logger-test-support'
|
||||
|
||||
const SESSION = 'session-1'
|
||||
|
||||
describe('an operation whose agent start throws', () => {
|
||||
it('is refused as a failed restart, with the error only in the log', async () => {
|
||||
const log = recordingStructuredAgentSessionLogger()
|
||||
const cause = new Error('EACCES: permission denied, open /Users/me/.orca/leases.json')
|
||||
const context = {
|
||||
deps: {
|
||||
logger: log.logger,
|
||||
store: {
|
||||
requireWritableRecord: () =>
|
||||
foundAgentSessionRecord(
|
||||
{
|
||||
sessionId: SESSION,
|
||||
provider: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/test/codex' },
|
||||
location: {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree',
|
||||
executionHostId: 'local',
|
||||
wslDistro: null
|
||||
}
|
||||
},
|
||||
{ claimKeyId: 'test-key', now: 0 }
|
||||
)
|
||||
}
|
||||
},
|
||||
sessions: new Map(),
|
||||
publishStatus: vi.fn(),
|
||||
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
|
||||
tasks: { trackAttach: (start: Promise<unknown>) => start },
|
||||
runtimeState: {
|
||||
acquireAborts: new StructuredAgentSessionAcquireAborts(),
|
||||
startFlights: new StructuredAgentSessionStartFlights(),
|
||||
startupAttempts: new StructuredAgentSessionStartupAttempts({ expire: () => {} })
|
||||
},
|
||||
reconcileLeases: () => Promise.reject(cause)
|
||||
}
|
||||
|
||||
const refused = await startStructuredAgentSessionAgentForOperation(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the store returns a complete record; the start throws at `reconcileLeases` before the remaining context is read.
|
||||
context as unknown as StructuredAgentSessionAttachContext,
|
||||
SESSION
|
||||
)
|
||||
|
||||
expect(refused).toEqual({
|
||||
ok: false,
|
||||
failure: { kind: 'hostFault' },
|
||||
refusal: {
|
||||
code: 'agent_session_owner_restart_failed',
|
||||
message: "The agent couldn't restart."
|
||||
}
|
||||
})
|
||||
expect(log.entries.map((entry) => entry.fields)).toEqual([
|
||||
{ scope: 'agent-start', sessionId: SESSION, error: cause }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('an option picked while the chat is at rest', () => {
|
||||
it('refuses a key the provider would not accept as a rejected option', async () => {
|
||||
const reviseSessionOptions = vi.fn()
|
||||
const refused = await recordStructuredAgentSessionOptionIntent(
|
||||
{
|
||||
store: {
|
||||
getRecord: () =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options.
|
||||
({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord,
|
||||
replaceSessionOptions: vi.fn(),
|
||||
reviseSessionOptions
|
||||
},
|
||||
agents: claudeAndCodexAgents()
|
||||
},
|
||||
{ sessionId: SESSION, fence: 1, publish: () => {}, now: () => 0 },
|
||||
{ key: 'notAnOption', value: 'x' }
|
||||
)
|
||||
|
||||
expect(refused).toMatchObject({
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_operation_invalid',
|
||||
details: { reason: 'optionRejected' }
|
||||
}
|
||||
})
|
||||
expect(reviseSessionOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
-10
@@ -62,16 +62,6 @@ it("keeps a required read's final answer over the child's later death", async ()
|
||||
expect(outcome).toMatchObject({ retry: 'never', cause: answer })
|
||||
})
|
||||
|
||||
it('answers an unanswered death after a successful open with the adapter verdict', async () => {
|
||||
ending = died
|
||||
expect(await settleStructuredAgentSessionProviderStarted(context(), OPENED)).toBe(died)
|
||||
expect(died.retry).toBe('safe')
|
||||
})
|
||||
|
||||
it('retires only its own owner once the child was replaced', async () => {
|
||||
expect(await settleStructuredAgentSessionProviderStarted(context('g-2'), OPENED)).toBe('stale')
|
||||
})
|
||||
|
||||
it('ends the start on a reported death while its bookkeeping is still blocked', async () => {
|
||||
ending = died
|
||||
const blocked = context()
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { StructuredAgentSessionStartFlights } from '../structured-agent-session-start-flight'
|
||||
|
||||
describe('start flight child retention', () => {
|
||||
it.each([10, 100])('retires a flight after %i duplicate publications', (count) => {
|
||||
const flights = new StructuredAgentSessionStartFlights()
|
||||
const flight = {
|
||||
attemptId: 'start',
|
||||
abort: () => {},
|
||||
outcome: Promise.resolve({ ok: true as const })
|
||||
}
|
||||
flights.register('chat', flight)
|
||||
const child = { generation: 'child', fence: 1 }
|
||||
for (let n = 0; n < count; n++) {
|
||||
flights.published('chat', { ...child })
|
||||
}
|
||||
expect(flights.get('chat')?.children).toEqual([child])
|
||||
expect(flights.ownsChild('chat', child)).toBe(true)
|
||||
const successor = { ...child, fence: 2 }
|
||||
flights.published('chat', successor)
|
||||
flights.published('chat', { ...successor })
|
||||
expect(flights.get('chat')?.children).toEqual([child, successor])
|
||||
flights.end('chat', flight.attemptId)
|
||||
flights.published('chat', child)
|
||||
expect(flights.has('chat')).toBe(false)
|
||||
expect(flights.ownsChild('chat', child)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
|
||||
import { createNativeChatConversationProjection } from './native-chat-conversation-projection'
|
||||
|
||||
function text(id: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: id }],
|
||||
timestamp: 1,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
it('preserves task conversion and mode-dependent goal omission across revisions and structural changes', () => {
|
||||
const frame = (id: string, kind: string, head: string): NativeChatMessage => ({
|
||||
...text(id),
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'provider notice',
|
||||
providerFrame: {
|
||||
provider: 'codex',
|
||||
kind,
|
||||
payload: { head, byteLength: head.length, digest: id, truncated: false }
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
const plan = frame(
|
||||
'plan',
|
||||
'notification:turn/plan/updated',
|
||||
'{"plan":[{"step":"Inspect","status":"completed"}]}'
|
||||
)
|
||||
const goal = frame('goal', 'notification:thread/goal/cleared', '{}')
|
||||
const invalid = frame('invalid', 'notification:turn/plan/updated', 'invalid json')
|
||||
const project = createNativeChatConversationProjection()
|
||||
expect(project([plan, goal, invalid], true).map((row) => row.id)).toEqual(['plan', 'invalid'])
|
||||
expect(project([plan], false)[0]?.blocks).toEqual([
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'update_plan',
|
||||
input: '{"plan":[{"step":"Inspect","status":"completed"}]}',
|
||||
state: 'completed'
|
||||
}
|
||||
])
|
||||
const changedRole = { ...goal, role: 'assistant' as const }
|
||||
const truncated = frame('truncated', 'notification:turn/plan/updated', '{"plan":[]}')
|
||||
if (truncated.blocks[0]?.type === 'text' && truncated.blocks[0].providerFrame) {
|
||||
truncated.blocks[0].providerFrame.payload.truncated = true
|
||||
}
|
||||
const planOutput = {
|
||||
id: 'plan',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'update_plan',
|
||||
input: '{"plan":[{"step":"Inspect","status":"completed"}]}',
|
||||
state: 'completed'
|
||||
}
|
||||
]
|
||||
}
|
||||
const goalOutput = {
|
||||
id: 'goal',
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'provider notice',
|
||||
providerFrame: {
|
||||
provider: 'codex',
|
||||
kind: 'notification:thread/goal/cleared',
|
||||
payload: { head: '{}', byteLength: 2, digest: 'goal', truncated: false }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
const invalidOutput = {
|
||||
id: 'invalid',
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'provider notice',
|
||||
providerFrame: {
|
||||
provider: 'codex',
|
||||
kind: 'notification:turn/plan/updated',
|
||||
payload: { head: 'invalid json', byteLength: 12, digest: 'invalid', truncated: false }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
const assistantGoalOutput = { ...goalOutput, role: 'assistant' }
|
||||
const truncatedOutput = {
|
||||
id: 'truncated',
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'provider notice',
|
||||
providerFrame: {
|
||||
provider: 'codex',
|
||||
kind: 'notification:turn/plan/updated',
|
||||
payload: { head: '{"plan":[]}', byteLength: 11, digest: 'truncated', truncated: true }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
for (const [rows, visible, hidden] of [
|
||||
[
|
||||
[plan, goal, invalid],
|
||||
[planOutput, goalOutput, invalidOutput],
|
||||
[planOutput, invalidOutput]
|
||||
],
|
||||
[[goal, plan], [goalOutput, planOutput], [planOutput]],
|
||||
[
|
||||
[changedRole, invalid],
|
||||
[assistantGoalOutput, invalidOutput],
|
||||
[assistantGoalOutput, invalidOutput]
|
||||
],
|
||||
[
|
||||
[truncated, plan, plan],
|
||||
[truncatedOutput, planOutput, planOutput],
|
||||
[truncatedOutput, planOutput, planOutput]
|
||||
],
|
||||
[[], [], []]
|
||||
] as const) {
|
||||
expect(project([...rows], false).map(({ id, role, blocks }) => ({ id, role, blocks }))).toEqual(
|
||||
visible
|
||||
)
|
||||
expect(project([...rows], true).map(({ id, role, blocks }) => ({ id, role, blocks }))).toEqual(
|
||||
hidden
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { nativeChatInstallSlotState } from './NativeChatInstallSlot'
|
||||
|
||||
it("offers Orca's install only to chats that run the managed runtime", () => {
|
||||
const notInstalled = {
|
||||
kind: 'active' as const,
|
||||
subject: { target: { kind: 'local' as const }, runtimeId: null, agent: 'antigravity' },
|
||||
removable: false,
|
||||
snapshot: {
|
||||
kind: 'known' as const,
|
||||
state: {
|
||||
agent: 'antigravity',
|
||||
revision: 1,
|
||||
available: true,
|
||||
version: '1.4.1',
|
||||
installedVersion: null,
|
||||
installedCurrent: false,
|
||||
source: null,
|
||||
operation: null
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(nativeChatInstallSlotState(notInstalled, 'strip', false).kind).toBe('install')
|
||||
expect(nativeChatInstallSlotState(notInstalled, 'strip', true)).toEqual({ kind: 'none' })
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, renderHook, waitFor } from '@testing-library/react'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useNativeChatSignIn } from './native-chat-sign-in'
|
||||
|
||||
const { state, remoteCreate, persistOrder, startHost, notifyFailure } = vi.hoisted(() => ({
|
||||
state: {
|
||||
@@ -34,45 +34,12 @@ vi.mock('@/runtime/hosted-terminals/creation/web-runtime-terminal-create-failure
|
||||
}))
|
||||
vi.mock('@/lib/wsl-host-user-start', () => ({ startStoppedWslHostForUserAct: startHost }))
|
||||
vi.mock('@/lib/claude-account-sign-in', () => ({ signInToClaudeAccount: vi.fn() }))
|
||||
import { NativeChatSignInContext, useNativeChatSignIn } from './native-chat-sign-in'
|
||||
import { NativeChatSignInButton } from './NativeChatSignInButton'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['opencode', 'opencode auth login'],
|
||||
['pi', 'pi'],
|
||||
['omp', 'omp']
|
||||
])(
|
||||
'opens %s sign-in as a plain terminal in the originating folder and pane',
|
||||
async (agent, command) => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatSignIn({
|
||||
agent,
|
||||
target: { kind: 'local' },
|
||||
worktreeId: 'folder:login-project',
|
||||
groupId: 'chat-pane',
|
||||
failure: null,
|
||||
startFailures: []
|
||||
})
|
||||
)
|
||||
act(() => result.current?.signIn())
|
||||
await waitFor(() => expect(result.current?.signingIn).toBe(false))
|
||||
expect(state.createTab).toHaveBeenCalledExactlyOnceWith(
|
||||
'folder:login-project',
|
||||
'chat-pane',
|
||||
undefined,
|
||||
{ pendingStartup: { command }, viewMode: 'terminal' }
|
||||
)
|
||||
expect(state.setActiveTabType).toHaveBeenCalledWith('terminal', 'folder:login-project')
|
||||
expect(persistOrder).toHaveBeenCalledWith('folder:login-project', 'login-terminal')
|
||||
expect(remoteCreate).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('runs sign-in on the paired chat host instead of making a local terminal', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatSignIn({
|
||||
@@ -141,62 +108,3 @@ it('leaves a failed WSL start to its existing notification owner', async () => {
|
||||
expect(remoteCreate).not.toHaveBeenCalled()
|
||||
expect(notifyFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([20, 200])('shares one sign-in operation across %i row actions', async (count) => {
|
||||
const pending = Promise.withResolvers<{ status: 'created' }>()
|
||||
remoteCreate.mockReturnValueOnce(pending.promise)
|
||||
const eligibleRender = vi.fn()
|
||||
const unrelatedRender = vi.fn()
|
||||
const rows = Array.from({ length: count }, (_, index) => (
|
||||
<div key={index}>
|
||||
<Profiler id={`auth-${index}`} onRender={eligibleRender}>
|
||||
<NativeChatSignInButton failureKind="notSignedIn" />
|
||||
</Profiler>
|
||||
<Profiler id={`other-${index}`} onRender={unrelatedRender}>
|
||||
<NativeChatSignInButton failureKind="providerRejected" />
|
||||
</Profiler>
|
||||
</div>
|
||||
))
|
||||
function Chat({ revision: _revision }: { revision: number }) {
|
||||
const signIn = useNativeChatSignIn({
|
||||
agent: 'pi',
|
||||
target: { kind: 'environment', environmentId: 'paired-host' },
|
||||
worktreeId: 'remote-project',
|
||||
failure: null,
|
||||
startFailures: []
|
||||
})
|
||||
return (
|
||||
<NativeChatSignInContext.Provider value={signIn}>{rows}</NativeChatSignInContext.Provider>
|
||||
)
|
||||
}
|
||||
const view = render(<Chat revision={0} />)
|
||||
eligibleRender.mockClear()
|
||||
unrelatedRender.mockClear()
|
||||
await act(async () => view.rerender(<Chat revision={1} />))
|
||||
expect(eligibleRender).not.toHaveBeenCalled()
|
||||
expect(unrelatedRender).not.toHaveBeenCalled()
|
||||
expect(remoteCreate).not.toHaveBeenCalled()
|
||||
expect(state.createTab).not.toHaveBeenCalled()
|
||||
const buttons = view.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(count)
|
||||
await act(async () => {
|
||||
for (const button of buttons) {
|
||||
fireEvent.click(button)
|
||||
}
|
||||
})
|
||||
expect(startHost).toHaveBeenCalledOnce()
|
||||
expect(remoteCreate).toHaveBeenCalledOnce()
|
||||
expect(unrelatedRender).not.toHaveBeenCalled()
|
||||
expect(eligibleRender).toHaveBeenCalledTimes(count)
|
||||
expect(buttons.every((button) => button instanceof HTMLButtonElement && button.disabled)).toBe(
|
||||
true
|
||||
)
|
||||
eligibleRender.mockClear()
|
||||
await act(async () => pending.resolve({ status: 'created' }))
|
||||
expect(eligibleRender).toHaveBeenCalledTimes(count)
|
||||
expect(unrelatedRender).not.toHaveBeenCalled()
|
||||
expect(buttons.every((button) => button instanceof HTMLButtonElement && !button.disabled)).toBe(
|
||||
true
|
||||
)
|
||||
expect(remoteCreate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -222,13 +222,3 @@ it('keeps floating chat splits local while a remote workspace is selected', asyn
|
||||
expect(createRemote).not.toHaveBeenCalled()
|
||||
expect(store.getState().activeWorktreeId).toBe('remote-workspace')
|
||||
})
|
||||
|
||||
it('retains the existing terminal-backed chat split route', () => {
|
||||
expect(
|
||||
runNativeChatSplitTarget({ kind: 'terminal-pane', terminalTabId: 'terminal' }, 'down')
|
||||
).toBe(true)
|
||||
expect(requestSplit).toHaveBeenCalledExactlyOnceWith({
|
||||
tabId: 'terminal',
|
||||
direction: 'horizontal'
|
||||
})
|
||||
})
|
||||
|
||||
-36
@@ -32,39 +32,3 @@ it('routes an authenticated-start refusal to sign-in instead of retrying the sam
|
||||
expect(signIn).toHaveBeenCalledOnce()
|
||||
expect(retry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['failed', 'visibility-unknown'] as const)(
|
||||
'says the create hand-back failure once and keeps Retry for %s',
|
||||
(lifecycle) => {
|
||||
const retry = vi.fn()
|
||||
const message = 'The message is back in the composer.'
|
||||
const notices = structuredSessionNotices({
|
||||
launch: { lifecycle, failure: null, retry },
|
||||
agentLabel: 'Codex',
|
||||
sessionError: message,
|
||||
composerError: null
|
||||
})
|
||||
render(<NativeChatComposerNotices notices={notices} />)
|
||||
expect(screen.getAllByText(message)).toHaveLength(1)
|
||||
expect(screen.queryByText('Chat could not be started.')).toBeNull()
|
||||
expect(screen.queryByText('Chat connection could not be confirmed.')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(retry).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps unrelated composer errors beside the hand-back notice', () => {
|
||||
render(
|
||||
<NativeChatComposerNotices
|
||||
notices={structuredSessionNotices({
|
||||
launch: { lifecycle: 'failed', failure: null, retry: () => {} },
|
||||
agentLabel: 'Codex',
|
||||
sessionError: 'The message is back in the composer.',
|
||||
composerError: { text: 'Attachment could not be added.', onDismiss: () => {} }
|
||||
})}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('The message is back in the composer.')).toBeTruthy()
|
||||
expect(screen.getByText('Attachment could not be added.')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -3,36 +3,6 @@ import { act, renderHook } from '@testing-library/react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { useNativeChatHistoryRetention } from './use-native-chat-history-retention'
|
||||
|
||||
it('holds parked history through hidden renders and releases on return, rebind and unmount', () => {
|
||||
const release = vi.fn()
|
||||
const retainHistory = vi.fn(() => release)
|
||||
const trimHistory = vi.fn()
|
||||
const view = renderHook(
|
||||
(source) =>
|
||||
useNativeChatHistoryRetention({ ...source, root: { current: null }, sourceKey: 'test' }),
|
||||
{
|
||||
initialProps: { retainHistory, trimHistory }
|
||||
}
|
||||
)
|
||||
act(() => {
|
||||
view.result.current.leaveEnd()
|
||||
view.result.current.leaveEnd()
|
||||
})
|
||||
view.rerender({ retainHistory, trimHistory })
|
||||
expect(retainHistory).toHaveBeenCalledOnce()
|
||||
expect(release).not.toHaveBeenCalled()
|
||||
act(() => view.result.current.returnToEnd())
|
||||
expect(release).toHaveBeenCalledOnce()
|
||||
act(() => view.result.current.leaveEnd())
|
||||
const nextRelease = vi.fn()
|
||||
const nextRetain = vi.fn(() => nextRelease)
|
||||
view.rerender({ retainHistory: nextRetain, trimHistory })
|
||||
expect(release).toHaveBeenCalledTimes(2)
|
||||
act(() => view.result.current.leaveEnd())
|
||||
view.unmount()
|
||||
expect(nextRelease).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves a mounted selection until it clears (shared source: %s)',
|
||||
(shared) => {
|
||||
|
||||
+1
-116
@@ -1,21 +1,15 @@
|
||||
import type * as RuntimeCapabilityProofModule from '@/runtime/rpc/compatibility/runtime-capability-proof'
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
mocks,
|
||||
answer,
|
||||
mutateWith,
|
||||
renderOptions,
|
||||
REOPENED,
|
||||
currentValue
|
||||
} from './use-structured-agent-session-options.test-fixture'
|
||||
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/rpc/environment-revisions/runtime-environment-revision'
|
||||
import { forgetStructuredSessionSelection } from '@/lib/agent-session/options/structured-agent-session-selection-controller'
|
||||
import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed'
|
||||
import type { AgentSessionStatusEvent } from '../../../../shared/agent-session/agent-session-wire'
|
||||
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import { useStructuredAgentSessionOptionStamp } from './structured-agent-session-status-hooks'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/runtime/rpc/compatibility/runtime-capability-proof', async (importOriginal) => ({
|
||||
@@ -23,115 +17,6 @@ vi.mock('@/runtime/rpc/compatibility/runtime-capability-proof', async (importOri
|
||||
runtimeEnvironmentSupportsCapability: async () => true
|
||||
}))
|
||||
|
||||
it.each([2, 20])('isolates mode refresh reads and renders with %s mounted chats', async (count) => {
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'server-1', createdAt: 1 }])
|
||||
forgetStructuredSessionSelection('session-1')
|
||||
for (let i = 0; i < count - 1; i++) {
|
||||
forgetStructuredSessionSelection(`other-${i}`)
|
||||
}
|
||||
const emitters = new Map<string, (event: AgentSessionStatusEvent) => void>()
|
||||
mocks.subscribeStatus.mockImplementation(async (target, onEvent) => {
|
||||
emitters.set(target.kind, onEvent)
|
||||
return { unsubscribe: () => {} }
|
||||
})
|
||||
let localMode: 'default' | 'plan' = 'default'
|
||||
let remoteMode: 'default' | 'plan' = 'default'
|
||||
const read = vi.fn(async (target: RuntimeClientTarget) => ({
|
||||
models: [],
|
||||
current: {
|
||||
interactionMode: target.kind === 'local' ? localMode : remoteMode,
|
||||
acceptedInputSources: { interactionMode: 'saved' }
|
||||
}
|
||||
}))
|
||||
answer({ options: read })
|
||||
const { mutate } = mutateWith(async () => null)
|
||||
const stampRenders = vi.fn()
|
||||
const stamp = renderHook(() => {
|
||||
stampRenders()
|
||||
return useStructuredAgentSessionOptionStamp('session-1', { kind: 'local' })
|
||||
})
|
||||
const affectedRenders = vi.fn()
|
||||
const unaffectedRenders = vi.fn()
|
||||
const remoteRenders = vi.fn()
|
||||
const first = renderOptions({ ...REOPENED, onRender: affectedRenders }, mutate)
|
||||
const sameChat = renderOptions({ ...REOPENED, onRender: affectedRenders }, mutate)
|
||||
const others = Array.from({ length: count - 1 }, (_, i) =>
|
||||
renderOptions({ ...REOPENED, sessionId: `other-${i}`, onRender: unaffectedRenders }, mutate)
|
||||
)
|
||||
const remote = renderOptions({ ...REOPENED, paired: true, onRender: remoteRenders }, mutate)
|
||||
const mounted = [first, sameChat, ...others, remote]
|
||||
const summary = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
agent: 'codex',
|
||||
status: 'idle' as const,
|
||||
latestPrompt: '',
|
||||
updatedAt: 1
|
||||
}
|
||||
const publish = (host: string, session = summary, mode: 'default' | 'plan' = 'default') =>
|
||||
act(async () =>
|
||||
emitters.get(host)?.({ type: 'status', session: { ...session, interactionMode: mode } })
|
||||
)
|
||||
try {
|
||||
for (const hook of mounted) {
|
||||
await waitFor(() =>
|
||||
expect(currentValue(hook.result.current.optionSnapshot, 'interactionMode')).toBe('default')
|
||||
)
|
||||
}
|
||||
await publish('local')
|
||||
await publish('environment')
|
||||
for (let i = 0; i < count - 1; i++) {
|
||||
await publish('local', { ...summary, sessionId: `other-${i}` })
|
||||
}
|
||||
await act(async () => {})
|
||||
read.mockClear()
|
||||
stampRenders.mockClear()
|
||||
affectedRenders.mockClear()
|
||||
unaffectedRenders.mockClear()
|
||||
remoteRenders.mockClear()
|
||||
await publish('local', { ...summary, sessionId: 'unmounted' }, 'plan')
|
||||
await publish('local', { ...summary, latestPrompt: 'transcript changed' })
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
expect(stampRenders).not.toHaveBeenCalled()
|
||||
expect(unaffectedRenders).not.toHaveBeenCalled()
|
||||
expect(remoteRenders).not.toHaveBeenCalled()
|
||||
|
||||
localMode = 'plan'
|
||||
await publish('local', summary, 'plan')
|
||||
for (const hook of [first, sameChat]) {
|
||||
await waitFor(() =>
|
||||
expect(currentValue(hook.result.current.optionSnapshot, 'interactionMode')).toBe('plan')
|
||||
)
|
||||
}
|
||||
expect(read.mock.calls.length).toBeGreaterThan(0)
|
||||
expect(read.mock.calls.length).toBeLessThanOrEqual(2)
|
||||
expect(affectedRenders).toHaveBeenCalled()
|
||||
expect(stampRenders).toHaveBeenCalled()
|
||||
expect(unaffectedRenders).not.toHaveBeenCalled()
|
||||
expect(remoteRenders).not.toHaveBeenCalled()
|
||||
|
||||
read.mockClear()
|
||||
stampRenders.mockClear()
|
||||
affectedRenders.mockClear()
|
||||
remoteMode = 'plan'
|
||||
await publish('environment', summary, 'plan')
|
||||
await waitFor(() =>
|
||||
expect(currentValue(remote.result.current.optionSnapshot, 'interactionMode')).toBe('plan')
|
||||
)
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(remoteRenders).toHaveBeenCalled()
|
||||
expect(affectedRenders).not.toHaveBeenCalled()
|
||||
expect(stampRenders).not.toHaveBeenCalled()
|
||||
expect(unaffectedRenders).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
stamp.unmount()
|
||||
mounted.forEach((hook) => hook.unmount())
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
mocks.subscribeStatus.mockImplementation(async () => ({ unsubscribe: vi.fn() }))
|
||||
}
|
||||
})
|
||||
|
||||
it('projects transmitted mode admission to the optimistic exit control and recovers after settlement', async () => {
|
||||
const sessionId = 'mode-admission-projection'
|
||||
forgetStructuredSessionSelection(sessionId)
|
||||
|
||||
@@ -136,48 +136,6 @@ function renderActions(
|
||||
}
|
||||
|
||||
describe('onboarding completion destination', () => {
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'opens the requested %s in the selected workspace after close',
|
||||
async (agent) => {
|
||||
const { result, openModal, update, onOnboardingChange } = renderActions(
|
||||
true,
|
||||
STEPS.length - 1,
|
||||
agent
|
||||
)
|
||||
await act(() => result.current.next())
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ outcome: 'completed', lastCompletedStep: 5 })
|
||||
)
|
||||
expect(launch).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
agent,
|
||||
worktreeId: 'folder:home',
|
||||
workspaceExecutionHostId: 'runtime:chosen',
|
||||
launchSource: 'onboarding',
|
||||
groupId: 'group',
|
||||
focusIntent
|
||||
})
|
||||
)
|
||||
expect(onOnboardingChange.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
launch.mock.invocationCallOrder[0]
|
||||
)
|
||||
launch.mock.calls[0][0].beforeSurfaceOpen()
|
||||
expect(reveal).toHaveBeenCalledWith(
|
||||
'folder:home',
|
||||
expect.objectContaining({
|
||||
agent,
|
||||
providesInitialSurface: true,
|
||||
executionHostId: 'runtime:chosen'
|
||||
})
|
||||
)
|
||||
expect(openModal).not.toHaveBeenCalled()
|
||||
expect(track).toHaveBeenCalledWith(
|
||||
'onboarding_completed',
|
||||
expect.objectContaining({ path: 'workspace' })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('reports a captured workspace disappearing while completion is saved, without opening a surface', async () => {
|
||||
const { result, persistCurrentStep } = renderActions(true, STEPS.length - 1, 'claude')
|
||||
const saved = Promise.withResolvers<{ ok: boolean }>()
|
||||
@@ -200,45 +158,6 @@ describe('onboarding completion destination', () => {
|
||||
expect(toastError).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retains Add Project when no workspace is selected', async () => {
|
||||
const { result, openModal } = renderActions(false)
|
||||
await act(() => result.current.next())
|
||||
expect(launch).not.toHaveBeenCalled()
|
||||
expect(openModal).toHaveBeenCalledWith('add-repo')
|
||||
expect(track).toHaveBeenCalledWith(
|
||||
'onboarding_completed',
|
||||
expect.objectContaining({ path: 'add_project_modal' })
|
||||
)
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'returns to the main page when setting up later, selected workspace=%s',
|
||||
async (hasSelectedWorkspace) => {
|
||||
const { result, openModal, update, updateSettings } = renderActions(
|
||||
hasSelectedWorkspace,
|
||||
STEPS.findIndex((step) => step.id === 'agent'),
|
||||
'claude'
|
||||
)
|
||||
await act(() => result.current.setUpLater())
|
||||
expect(updateSettings).toHaveBeenCalledWith({ defaultTuiAgent: 'claude' })
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
outcome: 'completed',
|
||||
lastCompletedStep: 5,
|
||||
checklist: { dismissed: false }
|
||||
})
|
||||
)
|
||||
expect(openModal).not.toHaveBeenCalled()
|
||||
if (hasSelectedWorkspace) {
|
||||
expect(launch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agent: 'claude', worktreeId: 'folder:home' })
|
||||
)
|
||||
} else {
|
||||
expect(launch).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps onboarding open for retry when setting up later cannot save completion', async () => {
|
||||
const { result, update, onOnboardingChange, openModal } = renderActions(
|
||||
true,
|
||||
@@ -303,66 +222,6 @@ describe('onboarding completion destination', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['chat', 'terminal'] as const)(
|
||||
'confirms first-page %s mode and launches the saved agent instead of a hidden auto-pick',
|
||||
async (surface) => {
|
||||
const { result, openModal } = renderActions(true, 0, 'claude', {
|
||||
surface,
|
||||
defaultAgent: 'codex'
|
||||
})
|
||||
await act(() => result.current.setUpLater())
|
||||
expect(state.settings).toEqual(
|
||||
expect.objectContaining({
|
||||
agentDefaultSurface: surface,
|
||||
defaultTuiAgent: 'codex'
|
||||
})
|
||||
)
|
||||
expect(launch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agent: 'codex', worktreeId: 'folder:home' })
|
||||
)
|
||||
expect(openModal).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['agent_surface', 'theme'] as const)(
|
||||
'preserves saved Blank on %s despite a hidden auto-pick',
|
||||
async (step) => {
|
||||
const { result } = renderActions(
|
||||
true,
|
||||
STEPS.findIndex((value) => value.id === step),
|
||||
'claude',
|
||||
{
|
||||
surface: 'chat',
|
||||
defaultAgent: 'blank'
|
||||
}
|
||||
)
|
||||
await act(() => result.current.setUpLater())
|
||||
expect(state.settings?.defaultTuiAgent).toBe('blank')
|
||||
expect(launch).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('confirms a visible agent or Blank without substituting an installed agent', async () => {
|
||||
const agentPage = STEPS.findIndex((step) => step.id === 'agent')
|
||||
const chosen = renderActions(true, agentPage, 'aider', { defaultAgent: 'codex' })
|
||||
await act(() => chosen.result.current.setUpLater())
|
||||
expect(state.settings?.defaultTuiAgent).toBe('aider')
|
||||
expect(launch).toHaveBeenCalledWith(expect.objectContaining({ agent: 'aider' }))
|
||||
cleanup()
|
||||
launch.mockClear()
|
||||
const blank = renderActions(true, agentPage, null, { defaultAgent: 'codex' })
|
||||
await act(() => blank.result.current.setUpLater())
|
||||
expect(state.settings?.defaultTuiAgent).toBe('blank')
|
||||
expect(launch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves ordinary Auto without replacing the saved preference', async () => {
|
||||
const { result } = renderActions(true, 0, 'codex')
|
||||
await act(() => result.current.setUpLater())
|
||||
expect(state.settings?.defaultTuiAgent).toBeNull()
|
||||
expect(launch).toHaveBeenCalledWith(expect.objectContaining({ agent: 'claude' }))
|
||||
})
|
||||
|
||||
it('retains the clicked workspace owner through deferred save and post-close discovery', async () => {
|
||||
const { result, updateSettings, onOnboardingChange } = renderActions(true, 0, 'claude', {
|
||||
surface: 'chat',
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useAppStore } from '@/store'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { getDefaultOnboardingState } from '../../../../shared/onboarding-defaults'
|
||||
import { useOnboardingFlow } from './use-onboarding-flow'
|
||||
import { usePersistCurrentStep } from './use-onboarding-flow-persistence'
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
@@ -60,56 +59,4 @@ describe('onboarding completion persistence', () => {
|
||||
expect(openModal).not.toHaveBeenCalled()
|
||||
expect(notify).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['claude', 'claude'],
|
||||
[null, 'blank']
|
||||
] as const)(
|
||||
'saves the visible %s selection when completing without visiting the agent page',
|
||||
async (selectedAgent, expectedAgent) => {
|
||||
const savedProgress = getDefaultOnboardingState()
|
||||
const saveProgress = vi.fn().mockResolvedValue(savedProgress)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { onboarding: { update: saveProgress } }
|
||||
})
|
||||
vi.spyOn(useAppStore.getState(), 'recordFeatureInteraction').mockResolvedValue(undefined)
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined)
|
||||
const onOnboardingChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
usePersistCurrentStep({
|
||||
currentStepId: 'notifications',
|
||||
agentSurface: 'terminal',
|
||||
selectedAgent,
|
||||
agentInteractedRef: { current: false },
|
||||
yoloPermissions: true,
|
||||
yoloPermissionsChosen: { current: false },
|
||||
theme: 'dark',
|
||||
settings: {
|
||||
...getDefaultSettings('/tmp'),
|
||||
defaultTuiAgent: 'codex',
|
||||
agentDefaultSurface: 'chat'
|
||||
},
|
||||
updateSettings,
|
||||
onboardingChecklist: savedProgress.checklist,
|
||||
onOnboardingChange,
|
||||
setError: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
expect(await result.current()).toEqual({ ok: true })
|
||||
})
|
||||
expect(updateSettings).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ defaultTuiAgent: expectedAgent, agentDefaultSurface: 'terminal' })
|
||||
)
|
||||
expect(saveProgress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastCompletedStep: 5,
|
||||
checklist: expect.objectContaining({ choseAgent: selectedAgent !== null })
|
||||
})
|
||||
)
|
||||
expect(onOnboardingChange).toHaveBeenCalledWith(savedProgress)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PluginHostListEntry } from '../../../../../preload/api-types'
|
||||
import {
|
||||
pluginListEntryFromInstalled,
|
||||
pluginListEntryFromListing
|
||||
} from '../list/plugin-list-entries'
|
||||
import { pluginAuthor, pluginAuthorCatalogId, pluginsBySamePublisher } from './plugin-author'
|
||||
import {
|
||||
pluginAuthorGithubUrl,
|
||||
pluginSourceGitUrl,
|
||||
pluginSourceReadmeUrl
|
||||
} from './plugin-source-links'
|
||||
|
||||
function listed(pluginKey: string, url: string, marketplaceSourceId = 'community') {
|
||||
return pluginListEntryFromListing({
|
||||
pluginKey,
|
||||
marketplaceSourceId,
|
||||
marketplaceName: 'Community',
|
||||
marketplaceOwner: 'catalog-owner',
|
||||
marketplaceCommit: 'abc',
|
||||
source: { kind: 'git', url, ref: 'main' },
|
||||
categories: [],
|
||||
official: false,
|
||||
bundled: false
|
||||
})
|
||||
}
|
||||
|
||||
function installed(
|
||||
source: Pick<NonNullable<PluginHostListEntry['source']>, 'kind' | 'reference'> &
|
||||
Partial<NonNullable<PluginHostListEntry['source']>>,
|
||||
isDev = false
|
||||
) {
|
||||
return pluginListEntryFromInstalled(
|
||||
{
|
||||
pluginKey: 'acme.notes',
|
||||
name: 'Notes',
|
||||
publisher: 'acme',
|
||||
version: '1.0.0',
|
||||
status: 'running',
|
||||
consentFingerprint: null,
|
||||
needsReconsent: false,
|
||||
isDev,
|
||||
official: false,
|
||||
bundled: false,
|
||||
capabilities: [],
|
||||
panels: [],
|
||||
commands: [],
|
||||
hasWorker: false,
|
||||
restarts: 0,
|
||||
source: { resolvedCommit: null, contentHash: 'b'.repeat(64), ...source }
|
||||
},
|
||||
listed('acme.notes', 'https://github.com/acme/notes').listing
|
||||
)
|
||||
}
|
||||
|
||||
describe('plugin publisher and source provenance', () => {
|
||||
it('links GitHub publishers only when the repository owner matches the publisher', () => {
|
||||
expect(pluginAuthorGithubUrl(listed('acme.notes', 'https://github.com/Acme/notes.git'))).toBe(
|
||||
'https://github.com/acme'
|
||||
)
|
||||
expect(pluginAuthorGithubUrl(listed('acme.notes', 'git@github.com:acme/notes.git'))).toBe(
|
||||
'https://github.com/acme'
|
||||
)
|
||||
expect(
|
||||
pluginAuthorGithubUrl(listed('victim.notes', 'https://github.com/mirror/notes'))
|
||||
).toBeNull()
|
||||
expect(pluginAuthorGithubUrl(listed('acme.notes', 'https://gitlab.com/acme/notes'))).toBeNull()
|
||||
expect(
|
||||
pluginAuthorGithubUrl(installed({ kind: 'local-path', reference: '/plugins/notes' }))
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('recommends only the same publisher label within the same catalog', () => {
|
||||
const entry = listed('acme.notes', 'https://github.com/acme/notes')
|
||||
const entries = {
|
||||
installed: [],
|
||||
available: [
|
||||
listed('acme.icons', 'https://github.com/Acme/icons'),
|
||||
listed('acme.other-host', 'https://gitlab.com/acme/other'),
|
||||
listed('acme.other-catalog', 'https://github.com/acme/other', 'unrelated')
|
||||
]
|
||||
}
|
||||
expect(pluginsBySamePublisher(entry, entries).map((item) => item.pluginKey)).toEqual([
|
||||
'acme.icons',
|
||||
'acme.other-host'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps installed mirror authors and recommendations tied to the recorded release', () => {
|
||||
const entry = installed({
|
||||
kind: 'marketplace',
|
||||
reference: 'https://github.com/mirrors/plugins.git (plugins/notes)',
|
||||
url: 'https://github.com/mirrors/plugins.git',
|
||||
path: 'plugins/notes',
|
||||
authorRepository: 'https://github.com/acme/notes',
|
||||
marketplace: {
|
||||
reference: 'https://github.com/catalog/plugins',
|
||||
resolvedCommit: 'a'.repeat(40)
|
||||
}
|
||||
})
|
||||
const other = {
|
||||
...entry,
|
||||
pluginKey: 'acme.icons',
|
||||
installed: { ...entry.installed!, pluginKey: 'acme.icons' }
|
||||
}
|
||||
expect(pluginAuthor(entry)).toEqual({
|
||||
publisher: 'acme',
|
||||
catalog: 'Community',
|
||||
provenance: 'catalog'
|
||||
})
|
||||
expect(pluginSourceGitUrl(entry)).toBe('https://github.com/acme/notes')
|
||||
expect(
|
||||
pluginsBySamePublisher(entry, { installed: [other], available: [] }).map(
|
||||
(item) => item.pluginKey
|
||||
)
|
||||
).toEqual(['acme.icons'])
|
||||
// A newer catalog record must not replace the installed release's attribution.
|
||||
const withoutListing = { ...entry, listing: null, updatable: false }
|
||||
expect(pluginSourceGitUrl(withoutListing)).toBe('https://github.com/acme/notes')
|
||||
expect(pluginAuthorCatalogId(withoutListing)).toBeNull()
|
||||
})
|
||||
|
||||
it('uses structured monorepo sources instead of parsing their display label', () => {
|
||||
const entry = installed({
|
||||
kind: 'marketplace',
|
||||
reference: 'https://github.com/acme/plugins.git (plugins/notes)',
|
||||
url: 'https://github.com/acme/plugins.git',
|
||||
path: 'plugins/notes'
|
||||
})
|
||||
expect(pluginSourceGitUrl(entry)).toBe('https://github.com/acme/plugins.git')
|
||||
})
|
||||
|
||||
it('revokes a recorded repository only when the installed catalog release marks it unavailable', () => {
|
||||
const entry = installed({
|
||||
kind: 'marketplace',
|
||||
reference: 'https://github.com/mirrors/plugins.git',
|
||||
url: 'https://github.com/mirrors/plugins.git',
|
||||
resolvedCommit: 'a'.repeat(40),
|
||||
authorRepository: 'https://github.com/acme/notes',
|
||||
marketplace: {
|
||||
reference: 'https://github.com/catalog/plugins',
|
||||
resolvedCommit: 'c'.repeat(40)
|
||||
}
|
||||
})
|
||||
entry.listing = {
|
||||
...entry.listing!,
|
||||
source: { kind: 'git', url: 'https://github.com/mirrors/plugins.git', ref: 'main' },
|
||||
release: {
|
||||
version: '1.0.0',
|
||||
commit: 'a'.repeat(40),
|
||||
contentHash: 'b'.repeat(64),
|
||||
execution: 'data',
|
||||
minimumOrca: '1.0.0',
|
||||
repository: 'https://github.com/acme/notes',
|
||||
sourceUnavailable: true
|
||||
}
|
||||
}
|
||||
expect(pluginSourceGitUrl(entry)).toBe('https://github.com/mirrors/plugins.git')
|
||||
expect(pluginSourceGitUrl({ ...entry, listing: null })).toBe('https://github.com/acme/notes')
|
||||
expect(pluginSourceGitUrl({ ...entry, updatable: false })).toBe('https://github.com/acme/notes')
|
||||
entry.listing.release!.commit = 'd'.repeat(40)
|
||||
expect(pluginSourceGitUrl(entry)).toBe('https://github.com/acme/notes')
|
||||
})
|
||||
|
||||
it('borrows a catalog repository only from a source and release matched listing', () => {
|
||||
const entry = installed({
|
||||
kind: 'marketplace',
|
||||
reference: 'Mirror · Notes',
|
||||
url: 'https://github.com/mirrors/plugins.git',
|
||||
path: 'plugins/notes',
|
||||
resolvedCommit: 'a'.repeat(40),
|
||||
marketplace: {
|
||||
reference: 'https://github.com/catalog/plugins',
|
||||
resolvedCommit: 'c'.repeat(40)
|
||||
}
|
||||
})
|
||||
entry.listing = {
|
||||
...entry.listing!,
|
||||
source: {
|
||||
kind: 'git',
|
||||
url: 'https://github.com/mirrors/plugins.git',
|
||||
ref: 'main',
|
||||
path: 'plugins/notes'
|
||||
},
|
||||
release: {
|
||||
version: '1.0.0',
|
||||
commit: 'a'.repeat(40),
|
||||
contentHash: 'b'.repeat(64),
|
||||
execution: 'data',
|
||||
minimumOrca: '1.0.0',
|
||||
repository: 'https://github.com/acme/notes'
|
||||
}
|
||||
}
|
||||
for (const listing of [
|
||||
{ ...entry.listing, source: { ...entry.listing.source, path: 'plugins/other' } },
|
||||
{
|
||||
...entry.listing,
|
||||
source: { ...entry.listing.source, url: 'https://github.com/other/plugins' }
|
||||
},
|
||||
{ ...entry.listing, release: { ...entry.listing.release!, commit: 'd'.repeat(40) } },
|
||||
{ ...entry.listing, release: { ...entry.listing.release!, contentHash: 'e'.repeat(64) } },
|
||||
{ ...entry.listing, release: { ...entry.listing.release!, sourceUnavailable: true as const } }
|
||||
]) {
|
||||
expect(pluginSourceGitUrl({ ...entry, listing })).toBe(
|
||||
'https://github.com/mirrors/plugins.git'
|
||||
)
|
||||
}
|
||||
expect(pluginSourceGitUrl({ ...entry, updatable: false })).toBe(
|
||||
'https://github.com/mirrors/plugins.git'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a mirror catalog claim separate from an account identity', () => {
|
||||
const entry = listed('victim.plugin', 'https://github.com/mirror/plugins')
|
||||
entry.listing!.release = {
|
||||
version: '1.0.0',
|
||||
commit: 'a'.repeat(40),
|
||||
contentHash: 'b'.repeat(64),
|
||||
execution: 'data',
|
||||
minimumOrca: '1.0.0',
|
||||
repository: 'https://github.com/victim/repo'
|
||||
}
|
||||
expect(pluginAuthor(entry)).toEqual({
|
||||
publisher: 'victim',
|
||||
catalog: 'Community',
|
||||
provenance: 'catalog'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
reference: 'https://github.com/acme/notes',
|
||||
url: 'https://github.com/acme/notes',
|
||||
path: undefined,
|
||||
readme: 'https://github.com/acme/notes/tree/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa#readme'
|
||||
},
|
||||
{
|
||||
reference: 'https://github.com/acme/plugins (plugins/notes)',
|
||||
url: 'https://github.com/acme/plugins',
|
||||
path: 'plugins/notes',
|
||||
readme:
|
||||
'https://github.com/acme/plugins/tree/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/plugins/notes#readme'
|
||||
}
|
||||
])(
|
||||
'restores older-host links only for the recorded location $reference',
|
||||
({ reference, path, url, readme }) => {
|
||||
const entry = installed({
|
||||
kind: 'marketplace',
|
||||
reference,
|
||||
resolvedCommit: 'a'.repeat(40),
|
||||
marketplace: {
|
||||
reference: 'https://github.com/catalog/plugins',
|
||||
resolvedCommit: 'c'.repeat(40)
|
||||
}
|
||||
})
|
||||
entry.listing!.source = { kind: 'git', url, ref: 'main', path }
|
||||
expect(pluginSourceGitUrl(entry)).toBe(url)
|
||||
expect(pluginSourceReadmeUrl(entry)).toBe(readme)
|
||||
expect(pluginSourceGitUrl({ ...entry, updatable: false })).toBeNull()
|
||||
expect(pluginSourceGitUrl({ ...entry, listing: null })).toBeNull()
|
||||
entry.listing!.source.path = 'plugins/other'
|
||||
expect(pluginSourceGitUrl(entry)).toBeNull()
|
||||
expect(pluginSourceReadmeUrl(entry)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('links an installed mirror README at its recorded path and commit, even after catalog changes', () => {
|
||||
const entry = installed({
|
||||
kind: 'marketplace',
|
||||
reference: 'mirror',
|
||||
url: 'https://gitlab.com/mirror/plugins',
|
||||
path: 'plugins/notes',
|
||||
resolvedCommit: 'a'.repeat(40),
|
||||
authorRepository: 'https://github.com/acme/notes'
|
||||
})
|
||||
expect(pluginSourceReadmeUrl(entry)).toBe(
|
||||
'https://gitlab.com/mirror/plugins/-/tree/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/plugins/notes#readme'
|
||||
)
|
||||
expect(pluginSourceReadmeUrl({ ...entry, listing: null })).toBe(
|
||||
'https://gitlab.com/mirror/plugins/-/tree/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/plugins/notes#readme'
|
||||
)
|
||||
entry.installed!.source!.resolvedCommit = null
|
||||
expect(pluginSourceReadmeUrl(entry)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not borrow a listing profile or repository for local and dev installs', () => {
|
||||
for (const isDev of [true, false]) {
|
||||
const entry = installed({ kind: 'local-path', reference: '/plugins/notes' }, isDev)
|
||||
expect(pluginSourceGitUrl(entry)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses a Git install source even when an unrelated listing is attached', () => {
|
||||
const entry = installed({ kind: 'git', reference: 'https://gitlab.com/acme/notes' })
|
||||
expect(pluginSourceGitUrl(entry)).toBe('https://gitlab.com/acme/notes')
|
||||
expect(pluginAuthor(entry)).toEqual({ publisher: 'acme', catalog: null, provenance: 'git' })
|
||||
expect(pluginAuthor({ ...entry, listing: null })).toEqual({
|
||||
publisher: 'acme',
|
||||
catalog: null,
|
||||
provenance: 'git'
|
||||
})
|
||||
})
|
||||
|
||||
it('attributes local and dev publisher labels to the manifest', () => {
|
||||
for (const isDev of [true, false]) {
|
||||
const entry = installed({ kind: 'local-path', reference: '/plugins/notes' }, isDev)
|
||||
expect(pluginAuthor(entry)).toEqual({
|
||||
publisher: 'acme',
|
||||
catalog: null,
|
||||
provenance: 'manifest'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps unnamed catalog and unknown install sources distinct', () => {
|
||||
const entry = installed({ kind: 'marketplace', reference: 'catalog' })
|
||||
entry.listing = null
|
||||
expect(pluginAuthor(entry)).toEqual({
|
||||
publisher: 'acme',
|
||||
catalog: null,
|
||||
provenance: 'catalog'
|
||||
})
|
||||
entry.installed!.source = undefined
|
||||
expect(pluginAuthor(entry)).toEqual({
|
||||
publisher: 'acme',
|
||||
catalog: null,
|
||||
provenance: 'unknown'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
import { pluginInstallErrorMessage } from './plugin-error-presentation'
|
||||
|
||||
// Why: keep presentation tests locked to pluginInstallTrustError production strings
|
||||
// so a "git" substring cannot silently re-route reserved errors to installGit.
|
||||
const reservedRefusal = (pluginKey: string): string =>
|
||||
`${pluginKey} uses a name reserved for official Orca plugins; Orca installs it only from its official catalog`
|
||||
|
||||
describe('pluginInstallErrorMessage', () => {
|
||||
it('explains how to rename a local fork of an official template', () => {
|
||||
const message = pluginInstallErrorMessage(
|
||||
new Error(reservedRefusal('stablyai.orca-portuguese'))
|
||||
)
|
||||
expect(message).toContain('reserved for official Orca plugins')
|
||||
expect(message).toContain('orca-plugin.json')
|
||||
expect(message).toContain('"orca-"')
|
||||
expect(message).toContain('"publisher"')
|
||||
})
|
||||
|
||||
it('does not mistake a reserved key containing "git" for a fetch failure', () => {
|
||||
const message = pluginInstallErrorMessage(new Error(reservedRefusal('github.tools')))
|
||||
expect(message).toContain('reserved for official Orca plugins')
|
||||
expect(message).not.toContain("Couldn't fetch")
|
||||
})
|
||||
|
||||
it('still maps genuine git fetch failures to the installGit message', () => {
|
||||
const message = pluginInstallErrorMessage(
|
||||
new Error('git fetch failed: could not clone repository from remote')
|
||||
)
|
||||
expect(message).toContain("Couldn't fetch this Git revision")
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
disambiguatedContentPluginNames,
|
||||
disambiguatedPluginContentLabels
|
||||
} from './plugin-name-disambiguation'
|
||||
|
||||
describe('plugin content labels', () => {
|
||||
it('suffixes the community theme, never the official one, by its key publisher', () => {
|
||||
const labels = disambiguatedPluginContentLabels([
|
||||
{
|
||||
id: 'plugin:stablyai.orca-catppuccin-theme/mocha',
|
||||
pluginKey: 'stablyai.orca-catppuccin-theme',
|
||||
label: 'Catppuccin Mocha'
|
||||
},
|
||||
{
|
||||
id: 'plugin:vale-c.catppuccin/mocha',
|
||||
pluginKey: 'vale-c.catppuccin',
|
||||
label: 'Catppuccin Mocha'
|
||||
},
|
||||
{
|
||||
id: 'plugin:vale-c.catppuccin/latte',
|
||||
pluginKey: 'vale-c.catppuccin',
|
||||
label: 'Catppuccin Latte'
|
||||
}
|
||||
])
|
||||
expect(labels.get('plugin:stablyai.orca-catppuccin-theme/mocha')).toBe('Catppuccin Mocha')
|
||||
expect(labels.get('plugin:vale-c.catppuccin/mocha')).toBe('Catppuccin Mocha (vale-c)')
|
||||
expect(labels.get('plugin:vale-c.catppuccin/latte')).toBe('Catppuccin Latte')
|
||||
})
|
||||
|
||||
it("keeps Orca's built-in names plain and suffixes a plugin entry sharing one", () => {
|
||||
const labels = disambiguatedPluginContentLabels(
|
||||
[{ id: 'plugin:acme.dracula/d', pluginKey: 'acme.dracula', label: 'Dracula' }],
|
||||
['Dracula']
|
||||
)
|
||||
expect(labels.get('plugin:acme.dracula/d')).toBe('Dracula (acme)')
|
||||
})
|
||||
|
||||
it('names clashing plugins once each', () => {
|
||||
const names = disambiguatedContentPluginNames([
|
||||
{ pluginKey: 'stablyai.orca-catppuccin-theme', pluginName: 'Catppuccin for Orca' },
|
||||
{ pluginKey: 'vale-c.catppuccin', pluginName: 'Catppuccin for Orca' },
|
||||
{ pluginKey: 'vale-c.catppuccin', pluginName: 'Catppuccin for Orca' }
|
||||
])
|
||||
expect(names.get('stablyai.orca-catppuccin-theme')).toBe('Catppuccin for Orca')
|
||||
expect(names.get('vale-c.catppuccin')).toBe('Catppuccin for Orca (vale-c)')
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { expect, it } from 'vitest'
|
||||
import { pluginListEntryFromListing, type PluginListEntry } from '../list/plugin-list-entries'
|
||||
import type { PluginPagePreview } from './use-plugin-catalog'
|
||||
import { usePluginPageModel } from './use-plugin-page-model'
|
||||
|
||||
it('pins catalog provenance with its listing until the newer entry is selected', async () => {
|
||||
const original = pluginListEntryFromListing(
|
||||
{
|
||||
pluginKey: 'acme.notes',
|
||||
marketplaceSourceId: 'first',
|
||||
marketplaceName: 'Community',
|
||||
marketplaceOwner: 'catalog',
|
||||
marketplaceCommit: 'a',
|
||||
source: { kind: 'git', url: 'https://github.com/acme/notes', ref: 'main' },
|
||||
categories: [],
|
||||
official: false,
|
||||
bundled: false
|
||||
},
|
||||
['first'],
|
||||
'https://github.com/first/catalog'
|
||||
)
|
||||
const newer = pluginListEntryFromListing(
|
||||
{
|
||||
...original.listing!,
|
||||
marketplaceSourceId: 'second',
|
||||
marketplaceCommit: 'b'
|
||||
},
|
||||
['second'],
|
||||
'https://gitlab.com/second/catalog'
|
||||
)
|
||||
const fetchPreview = () => new Promise<PluginPagePreview>(() => undefined)
|
||||
let model: ReturnType<typeof usePluginPageModel> | undefined
|
||||
function Harness({ entry }: { entry: PluginListEntry }) {
|
||||
model = usePluginPageModel({ entry, fetchPreview, refreshing: false })
|
||||
return null
|
||||
}
|
||||
const root = createRoot(document.createElement('div'))
|
||||
try {
|
||||
await act(async () => root.render(<Harness entry={original} />))
|
||||
expect(model?.entry.verifiedSource).toEqual({
|
||||
kind: 'catalog',
|
||||
catalog: 'Community',
|
||||
owner: 'github.com/first'
|
||||
})
|
||||
await act(async () => root.render(<Harness entry={newer} />))
|
||||
expect(model?.entry.verifiedSource).toEqual({
|
||||
kind: 'catalog',
|
||||
catalog: 'Community',
|
||||
owner: 'github.com/first'
|
||||
})
|
||||
expect(model?.listing?.marketplaceSourceId).toBe('first')
|
||||
await act(async () => model?.showNewer())
|
||||
expect(model?.entry.verifiedSource).toEqual({
|
||||
kind: 'catalog',
|
||||
catalog: 'Community',
|
||||
owner: 'gitlab.com/second'
|
||||
})
|
||||
expect(model?.listing?.marketplaceSourceId).toBe('second')
|
||||
} finally {
|
||||
act(() => root.unmount())
|
||||
}
|
||||
})
|
||||
@@ -93,30 +93,6 @@ describe('usePluginPageNavigation', () => {
|
||||
expect(document.activeElement).toBe(container.querySelector('[data-plugin-row-name]'))
|
||||
})
|
||||
|
||||
it('clears history for catalog requests and discards removed pages', async () => {
|
||||
await mountOpen()
|
||||
await act(async () => nav?.open('acme.icons'))
|
||||
await act(async () => nav?.leave('acme.notes'))
|
||||
expect(nav?.openKey).toBe('acme.icons')
|
||||
await act(async () => nav?.back())
|
||||
expect(nav?.openKey).toBeNull()
|
||||
await act(async () => nav?.open('acme.notes'))
|
||||
await act(async () => nav?.open('acme.icons'))
|
||||
await act(async () => nav?.toCatalog())
|
||||
expect(nav?.openKey).toBeNull()
|
||||
await act(async () => nav?.open('acme.third'))
|
||||
await act(async () => nav?.back())
|
||||
expect(nav?.openKey).toBeNull()
|
||||
})
|
||||
|
||||
it('returns to the originating detail page after replacing a completed review', async () => {
|
||||
await mountOpen()
|
||||
await act(async () => nav?.open('acme.tasks'))
|
||||
await act(async () => nav?.open('acme.third', { replace: true }))
|
||||
await act(async () => nav?.back())
|
||||
expect(nav?.openKey).toBe('acme.notes')
|
||||
})
|
||||
|
||||
it('pauses A when visiting B, keeps B decisions outside the queue, and resumes A before the next review', async () => {
|
||||
await mountOpen()
|
||||
await act(async () => reviews?.enqueue(['acme.notes', 'acme.third']))
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { CircleDashed } from 'lucide-react'
|
||||
import { acceptPRCheckObservation } from '../../../shared/github/complete-pr-check-owner'
|
||||
import { getCheckCounts, getOverallCheckCounts, getChecksSummaryLabel } from './pr-check-counts'
|
||||
import { ChecksTabLayouts } from './pull-request-page/checks/layouts'
|
||||
import { ChecksCompactHeader } from './pull-request-page/checks/toolbar'
|
||||
import { ChecksTabCompactHeader } from './github-item-dialog/inspect-pull-request/checks-tab-header'
|
||||
import { getChecksTabSummaryPresentation } from './github-item-dialog/inspect-pull-request/checks-tab-summary'
|
||||
import { PRTriageStrip } from './right-sidebar/checks-panel/presentation/triage-strip'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import type { PRCheckDetail } from '../../../shared/github/check-types'
|
||||
@@ -41,138 +35,6 @@ afterEach(async () => {
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
it.each([ChecksCompactHeader, ChecksTabCompactHeader])(
|
||||
'keeps row approval chips while only current associated rows explain an owner failure',
|
||||
(Header) => {
|
||||
const accepted = acceptPRCheckObservation({ ...owner, checksSummary: blocked }, null, context)!
|
||||
const counts = getCheckCounts(rows)
|
||||
expect(counts).toMatchObject({ passing: 0, failing: 0, needsAction: 1 })
|
||||
const overall = getOverallCheckCounts(rows, accepted, accepted)
|
||||
render(
|
||||
<Header
|
||||
{...getChecksTabSummaryPresentation(overall)}
|
||||
counts={overall}
|
||||
summaryLabel={getChecksSummaryLabel(rows, accepted, accepted)}
|
||||
listLength={1}
|
||||
refreshAction={<button>Refresh</button>}
|
||||
rerunAction={null}
|
||||
secondaryActions={null}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('1 check needs action')).toBeTruthy()
|
||||
cleanup()
|
||||
const newer = acceptPRCheckObservation(
|
||||
{ ...owner, checksSummary: { ...blocked } },
|
||||
accepted,
|
||||
context
|
||||
)!
|
||||
const nextCounts = getOverallCheckCounts(rows, newer, accepted)
|
||||
render(
|
||||
<Header
|
||||
{...getChecksTabSummaryPresentation(nextCounts)}
|
||||
counts={nextCounts}
|
||||
summaryLabel={getChecksSummaryLabel(rows, newer, accepted)}
|
||||
listLength={1}
|
||||
refreshAction={<button>Refresh</button>}
|
||||
rerunAction={null}
|
||||
secondaryActions={null}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('1 check failing')).toBeTruthy()
|
||||
expect(
|
||||
getOverallCheckCounts(
|
||||
rows,
|
||||
acceptPRCheckObservation(
|
||||
{ ...owner, checksSummary: { ...blocked, failed: 2, total: 102 } },
|
||||
null,
|
||||
context
|
||||
)!,
|
||||
accepted
|
||||
).failing
|
||||
).toBe(2)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['page', 'compact'] as const)(
|
||||
'does not claim zero whole checks in the empty %s renderer',
|
||||
async (variant) => {
|
||||
i18n.addResourceBundle(
|
||||
'es',
|
||||
'translation',
|
||||
{
|
||||
auto: {
|
||||
components: {
|
||||
right: { sidebar: { ChecksPanel: { '976cefd02f': 'Comprobaciones no disponibles' } } },
|
||||
prCheckCounts: { noCheckDetails: 'No hay detalles de comprobaciones' }
|
||||
}
|
||||
}
|
||||
},
|
||||
true,
|
||||
true
|
||||
)
|
||||
await i18n.changeLanguage('es')
|
||||
const accepted = acceptPRCheckObservation(owner, null, context)!
|
||||
const summaryLabel = getChecksSummaryLabel([], accepted)
|
||||
render(
|
||||
<ChecksTabLayouts
|
||||
owner={accepted}
|
||||
loading={false}
|
||||
listLength={0}
|
||||
variant={variant}
|
||||
compactHeader={<div>{summaryLabel}</div>}
|
||||
actions={<button>Refresh</button>}
|
||||
SummaryIcon={CircleDashed}
|
||||
summaryColor=""
|
||||
summaryLabel={summaryLabel}
|
||||
counts={getCheckCounts([])}
|
||||
summaryPending={false}
|
||||
sorted={[]}
|
||||
renderCheckRow={() => <div />}
|
||||
fixChecksAgentDialog={<span />}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Comprobaciones no disponibles')).toBeTruthy()
|
||||
expect(screen.getByText('No hay detalles de comprobaciones')).toBeTruthy()
|
||||
expect(screen.queryByText('No checks found')).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the row Fix action available when the complete owner is unavailable', () => {
|
||||
const accepted = acceptPRCheckObservation(owner, null, context)!
|
||||
render(
|
||||
<PRTriageStrip
|
||||
review={accepted}
|
||||
checks={[{ name: 'Build', status: 'completed', conclusion: 'failure', url: null }]}
|
||||
isResolvingConflictsWithAI={false}
|
||||
onResolveConflictsWithAI={() => {}}
|
||||
isFixingChecksWithAI={false}
|
||||
onFixChecksWithAI={() => {}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Checks unavailable')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Fix' }).hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a successful whole owner presentation when independent rows still contain an old failure', () => {
|
||||
const accepted = acceptPRCheckObservation(
|
||||
{ ...owner, checksSummary: { ...blocked, state: 'success', total: 1, passed: 1, failed: 0 } },
|
||||
null,
|
||||
context
|
||||
)!
|
||||
render(
|
||||
<PRTriageStrip
|
||||
review={accepted}
|
||||
checks={[{ name: 'Build', status: 'completed', conclusion: 'failure', url: null }]}
|
||||
isResolvingConflictsWithAI={false}
|
||||
onResolveConflictsWithAI={() => {}}
|
||||
isFixingChecksWithAI={false}
|
||||
onFixChecksWithAI={() => {}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('No blocking PR action')).toBeTruthy()
|
||||
expect(screen.queryByText('0 failing checks')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps a failed parent pipeline fixable with empty jobs: %s', (empty) => {
|
||||
const fix = vi.fn()
|
||||
const props = {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { GITHUB_COMPLETE_PR_CHECK_OBSERVATION_CAPABILITY as capability } from '.
|
||||
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
|
||||
import type { PRCheckDetail } from '../../../shared/github/check-types'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { acceptPRCheckObservation } from '../../../shared/github/complete-pr-check-owner'
|
||||
import {
|
||||
patchCachedPRChecks,
|
||||
subscribeWorkItemDetailsCache,
|
||||
@@ -155,53 +154,3 @@ it.each([PageChecks, DialogChecks])(
|
||||
expect(screen.getByText('1 check failing')).toBeTruthy()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([PageChecks, DialogChecks])(
|
||||
'keeps approval explanation across its cache echo and retires it for a newer owner',
|
||||
async (Tab) => {
|
||||
lookup.mockResolvedValue({ ...initial, checksSummary: summary })
|
||||
rows.mockResolvedValue([
|
||||
{
|
||||
name: 'Approval',
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
url: null,
|
||||
observedHeadSha: 'a'.repeat(40)
|
||||
}
|
||||
])
|
||||
const view = render(<Harness Tab={Tab} />)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
|
||||
})
|
||||
expect(screen.getByText('1 check needs action')).toBeTruthy()
|
||||
const newer = acceptPRCheckObservation({ ...initial, checksSummary: { ...summary } }, null, {
|
||||
scope: 'workspace:local:local',
|
||||
complete: true,
|
||||
current: true
|
||||
})!
|
||||
view.rerender(<Harness Tab={Tab} parent={newer} />)
|
||||
expect(screen.getByText('1 check failing')).toBeTruthy()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([PageChecks, DialogChecks])(
|
||||
'keeps a mismatched approval head conservative after its cache echo',
|
||||
async (Tab) => {
|
||||
lookup.mockResolvedValue({ ...initial, checksSummary: summary })
|
||||
rows.mockResolvedValue([
|
||||
{
|
||||
name: 'Approval',
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
url: null,
|
||||
observedHeadSha: 'b'.repeat(40)
|
||||
}
|
||||
])
|
||||
render(<Harness Tab={Tab} />)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
|
||||
})
|
||||
expect(screen.getByText('1 check failing')).toBeTruthy()
|
||||
expect(screen.getByText('Approval')).toBeTruthy()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -301,61 +301,6 @@ it('refreshes the saved method after reconnecting to a restarted host with the s
|
||||
expect(screen.getByRole('combobox')).toHaveTextContent('Gemini Enterprise')
|
||||
})
|
||||
|
||||
it.each(['accounts', 'provider'] as const)(
|
||||
'%s preserves another client’s active and settled sign-in status',
|
||||
async (surface) => {
|
||||
const target = { kind: 'environment', environmentId: `other-client-${surface}` } as const
|
||||
streams.initial.set(key(target), {
|
||||
revision: 0,
|
||||
auth: 'unknown',
|
||||
flow: { phase: 'waiting', flowId: null }
|
||||
})
|
||||
vi.mocked(callAntigravityChatAuthConfig).mockResolvedValue(personal)
|
||||
renderSurface(surface, target)
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeEnabled())
|
||||
expect(screen.getByRole('button', { name: 'Sign in' })).toBeDisabled()
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Sign-in is in progress in another client.'
|
||||
)
|
||||
await act(async () => {
|
||||
streams.handlers
|
||||
.get(key(target))
|
||||
?.onEvent({ revision: 1, auth: 'unknown', flow: { phase: 'expired', flowId: null } })
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Sign-in timed out. Try again.')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['accounts', 'provider'] as const)(
|
||||
'%s preserves signed-in host context and explains a changed method',
|
||||
async (surface) => {
|
||||
const target = { kind: 'environment', environmentId: `signed-in-${surface}` } as const
|
||||
streams.initial.set(key(target), { revision: 0, auth: 'signedIn', flow: null })
|
||||
let current: AntigravityChatAuthConfig = personal
|
||||
vi.mocked(callAntigravityChatAuthConfig).mockImplementation(async (_owner, config) => {
|
||||
if (config) {
|
||||
current = config
|
||||
}
|
||||
return current
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderSurface(surface, target)
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeEnabled())
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Signed in on Test host.')
|
||||
expect(screen.getByRole('button', { name: 'Change account' })).toBeEnabled()
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.click(screen.getByRole('option', { name: 'Gemini Enterprise' }))
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Sign in again to use Gemini Enterprise.')
|
||||
await user.type(screen.getByLabelText('Google Cloud project'), enterprise.gcpProject)
|
||||
await user.type(screen.getByLabelText('Google Cloud location'), enterprise.gcpLocation)
|
||||
await user.tab()
|
||||
await act(async () => {
|
||||
streams.handlers.get(key(target))?.onEvent({ revision: 1, auth: 'unknown', flow: null })
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Sign in again to use Gemini Enterprise.')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['accounts', 'provider'] as const)(
|
||||
'%s uses its single Sign in action to reread a failed load before preparing Enterprise',
|
||||
async (surface) => {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useNativeChatMentionFiles } from './native-chat/use-native-chat-mention-files'
|
||||
import {
|
||||
flushEffects,
|
||||
listRuntimeFilesMock,
|
||||
makeFolderWorkspace
|
||||
} from './quick-open-file-list-test-harness'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
|
||||
vi.mock('@/runtime/files/search/runtime-file-search-client', async () => {
|
||||
const mocks = await import('./__mocks__/quick-open-runtime-file-client')
|
||||
return {
|
||||
listRuntimeFiles: mocks.listRuntimeFilesMock,
|
||||
cancelRuntimeFileList: mocks.cancelRuntimeFileListMock,
|
||||
searchRuntimeFilePaths: mocks.searchRuntimeFilePathsMock
|
||||
}
|
||||
})
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
afterEach(cleanup)
|
||||
|
||||
it.each([
|
||||
{ hostId: 'local', path: '/local/repo', connectionId: undefined },
|
||||
{ hostId: 'ssh:box', path: '/remote/repo', connectionId: 'box' }
|
||||
] as const)(
|
||||
'lists chat mentions from the selected $hostId twin of a folder workspace on two hosts',
|
||||
async ({ hostId, path, connectionId }) => {
|
||||
const twin = { id: 'fw-twin' }
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: folderWorkspaceKey('fw-twin'),
|
||||
activeWorkspaceExecutionHostId: hostId,
|
||||
repos: [],
|
||||
projectGroups: [],
|
||||
worktreesByRepo: {},
|
||||
folderWorkspaces: [
|
||||
makeFolderWorkspace({ ...twin, folderPath: '/local/repo', executionHostId: 'local' }),
|
||||
makeFolderWorkspace({
|
||||
...twin,
|
||||
folderPath: '/remote/repo',
|
||||
connectionId: 'box',
|
||||
executionHostId: 'ssh:box'
|
||||
})
|
||||
]
|
||||
})
|
||||
renderHook(() =>
|
||||
useNativeChatMentionFiles({
|
||||
// A typed query debounces the remote search; empty lists the catalog at once.
|
||||
query: '',
|
||||
terminalTabId: 'chat',
|
||||
structuredWorktreeId: folderWorkspaceKey('fw-twin')
|
||||
})
|
||||
)
|
||||
await flushEffects()
|
||||
expect(listRuntimeFilesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktreePath: path, connectionId }),
|
||||
expect.anything()
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -1,79 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import type { RuntimeFileListState } from './quick-open-file-list'
|
||||
import {
|
||||
cancelRuntimeFileListMock,
|
||||
flushEffects,
|
||||
listRuntimeFilesMock,
|
||||
makeRemoteWorktree,
|
||||
renderProbe,
|
||||
searchRuntimeFilePathsMock,
|
||||
seedRemoteWorktree
|
||||
} from './quick-open-file-list-test-harness'
|
||||
|
||||
vi.mock('@/runtime/files/search/runtime-file-search-client', async () => {
|
||||
const mocks = await import('./__mocks__/quick-open-runtime-file-client')
|
||||
return {
|
||||
listRuntimeFiles: mocks.listRuntimeFilesMock,
|
||||
cancelRuntimeFileList: mocks.cancelRuntimeFileListMock,
|
||||
searchRuntimeFilePaths: mocks.searchRuntimeFilePathsMock
|
||||
}
|
||||
})
|
||||
|
||||
async function replaceWorktrees(worktrees: Worktree[]): Promise<void> {
|
||||
await act(async () => {
|
||||
useAppStore.setState({ worktreesByRepo: { 'repo-remote': worktrees } })
|
||||
})
|
||||
await flushEffects()
|
||||
}
|
||||
|
||||
it('keeps a settled inventory through metadata updates', async () => {
|
||||
seedRemoteWorktree()
|
||||
const states: RuntimeFileListState[] = []
|
||||
await renderProbe({ enabled: true, states, worktreeId: 'wt-remote' })
|
||||
const settledFiles = states.at(-1)?.files
|
||||
const initialRequest = listRuntimeFilesMock.mock.calls[0]?.[1]
|
||||
const rendersBeforeUpdates = states.length
|
||||
|
||||
await replaceWorktrees([{ ...makeRemoteWorktree(), head: 'commit-1' }])
|
||||
await replaceWorktrees([{ ...makeRemoteWorktree(), head: 'commit-2' }])
|
||||
|
||||
expect(listRuntimeFilesMock).toHaveBeenCalledOnce()
|
||||
expect(cancelRuntimeFileListMock).not.toHaveBeenCalled()
|
||||
expect(initialRequest?.signal?.aborted).toBe(false)
|
||||
expect(states.slice(rendersBeforeUpdates).every((state) => !state.loading)).toBe(true)
|
||||
expect(states.at(-1)?.files).toBe(settledFiles)
|
||||
})
|
||||
|
||||
it('keeps an in-flight query through metadata updates but replaces it for changed exclusions', async () => {
|
||||
vi.useFakeTimers()
|
||||
seedRemoteWorktree()
|
||||
searchRuntimeFilePathsMock.mockReturnValue(new Promise(() => {}))
|
||||
try {
|
||||
await renderProbe({ enabled: true, states: [], query: 'target', worktreeId: 'wt-remote' })
|
||||
await act(async () => vi.advanceTimersByTimeAsync(120))
|
||||
const initialRequest = searchRuntimeFilePathsMock.mock.calls[0]?.[1]
|
||||
await replaceWorktrees([{ ...makeRemoteWorktree(), comment: 'Updated comment' }])
|
||||
await act(async () => vi.advanceTimersByTimeAsync(120))
|
||||
expect(initialRequest?.signal?.aborted).toBe(false)
|
||||
expect(searchRuntimeFilePathsMock).toHaveBeenCalledOnce()
|
||||
|
||||
const nested = { ...makeRemoteWorktree(), id: 'nested', path: '/srv/remote/nested' }
|
||||
await replaceWorktrees([makeRemoteWorktree(), nested])
|
||||
expect(initialRequest?.signal?.aborted).toBe(true)
|
||||
await act(async () => vi.advanceTimersByTimeAsync(120))
|
||||
expect(searchRuntimeFilePathsMock).toHaveBeenCalledTimes(2)
|
||||
expect(searchRuntimeFilePathsMock.mock.calls[1]?.[1].excludePaths).toEqual([nested.path])
|
||||
|
||||
await replaceWorktrees([makeRemoteWorktree()])
|
||||
await act(async () => vi.advanceTimersByTimeAsync(120))
|
||||
expect(searchRuntimeFilePathsMock).toHaveBeenCalledTimes(3)
|
||||
expect(searchRuntimeFilePathsMock.mock.calls[2]?.[1].excludePaths).toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
@@ -86,35 +86,3 @@ it('cancels the producer on close and ignores its late result after reopening',
|
||||
expect(await first).toBeUndefined()
|
||||
expect(mocks.list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('cancels eligibility on owner/root changes while preserving ordinary search on errors', async () => {
|
||||
const cache: QuickOpenRecentCache = { current: null }
|
||||
mocks.list.mockRejectedValue(new Error('Update host'))
|
||||
const result = await mergeQuickOpenRecentCandidates({
|
||||
cache,
|
||||
key: 'root-a',
|
||||
context,
|
||||
options: { rootPath: '/repo' },
|
||||
candidatePaths: ['recent.ts'],
|
||||
result: { files: ['recent.ts', 'ordinary.ts'], truncated: true },
|
||||
cancelled: () => false
|
||||
})
|
||||
expect(result?.files).toEqual(['recent.ts', 'ordinary.ts'])
|
||||
expect(result?.recentError).toBe('Recent files could not be checked: Update host')
|
||||
})
|
||||
|
||||
it('uses fresh complete inventory membership without a second traversal', async () => {
|
||||
const result = { files: ['ordinary.ts', 'recent.ts'], truncated: false }
|
||||
expect(
|
||||
await mergeQuickOpenRecentCandidates({
|
||||
result,
|
||||
completeInventory: true,
|
||||
candidatePaths: ['recent.ts', 'deleted.ts'],
|
||||
cache: { current: null },
|
||||
key: 'full',
|
||||
context,
|
||||
options: { rootPath: '/repo' },
|
||||
cancelled: () => false
|
||||
})
|
||||
).toBe(result)
|
||||
expect(mocks.list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
-246
@@ -14,10 +14,7 @@ import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import FolderWorkspaceSourceControlPanel from './FolderWorkspaceSourceControlPanel'
|
||||
import { SourceControlPanel } from './source-control/panel/panel'
|
||||
import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context'
|
||||
import { createRunningCommitMessageGenerationRecord } from '@/store/slices/commit-message-generation'
|
||||
import type { PullRequestGenerationRecord } from '@/store/slices/hosted-review/generation/pull-request-generation-state'
|
||||
|
||||
const probes = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
@@ -165,31 +162,6 @@ async function scrollTo(ids: readonly string[]): Promise<void> {
|
||||
}
|
||||
})
|
||||
}
|
||||
function writeUnrelatedGeneration(): void {
|
||||
const context = {
|
||||
worktreeId: 'other::/elsewhere',
|
||||
worktreePath: '/elsewhere',
|
||||
requestId: 1,
|
||||
runtimeTarget: { kind: 'local' as const }
|
||||
}
|
||||
const pr: PullRequestGenerationRecord = {
|
||||
context: { ...context, repoId: 'other', branch: 'other' },
|
||||
seed: { base: 'main', title: '', body: '', draft: false },
|
||||
seedFieldRevisions: { base: 0, title: 0, body: 0, draft: 0 },
|
||||
requiresPushBeforeCreate: false,
|
||||
status: 'running',
|
||||
result: null,
|
||||
error: null,
|
||||
hydrated: false
|
||||
}
|
||||
useAppStore
|
||||
.getState()
|
||||
.setCommitMessageGenerationRecord(
|
||||
context.worktreeId,
|
||||
createRunningCommitMessageGenerationRecord(context)
|
||||
)
|
||||
useAppStore.getState().setPullRequestGenerationRecord(context.worktreeId, pr)
|
||||
}
|
||||
|
||||
const BODY_OPERATIONS = ['compare', 'history', 'submodules', 'upstream', 'eligibility'] as const
|
||||
let root: Root
|
||||
@@ -318,224 +290,6 @@ async function render(node: ReactNode): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function bodyWork(worktrees: readonly Worktree[]) {
|
||||
const idByPath = new Map(worktrees.map((row) => [row.path, row.id]))
|
||||
return Object.fromEntries(
|
||||
BODY_OPERATIONS.map((operation) => {
|
||||
const ids = probes[operation].mock.calls.map(
|
||||
([key]: unknown[]) => idByPath.get(String(key)) ?? String(key)
|
||||
)
|
||||
return [operation, { calls: ids.length, worktrees: [...new Set(ids)].sort() }]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
it('runs expanded body work only for on-screen sections, at mount and over the safety interval', async () => {
|
||||
const counts: unknown[] = []
|
||||
for (const count of [3, 30]) {
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
const worktrees = setup(count)
|
||||
const visible = [worktrees[0]!.id, worktrees[1]!.id]
|
||||
probes.visible.clear()
|
||||
for (const id of visible) {
|
||||
probes.visible.add(id)
|
||||
}
|
||||
for (const probe of [probes.upstreamRef, ...BODY_OPERATIONS.map((name) => probes[name])]) {
|
||||
probe.mockClear()
|
||||
}
|
||||
await render(<FolderWorkspaceSourceControlPanel />)
|
||||
const atMount = bodyWork(worktrees)
|
||||
expect(atMount.upstream!.calls).toBeGreaterThan(0)
|
||||
expect(atMount.compare!.worktrees).toEqual(visible.toSorted())
|
||||
expect(atMount.compare!.calls).toBeGreaterThanOrEqual(visible.length)
|
||||
const intervals: ReturnType<typeof bodyWork>[] = []
|
||||
for (let tick = 0; tick < 2; tick++) {
|
||||
for (const operation of BODY_OPERATIONS) {
|
||||
probes[operation].mockClear()
|
||||
}
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
intervals.push(bodyWork(worktrees))
|
||||
}
|
||||
for (const snapshot of [atMount, ...intervals]) {
|
||||
for (const work of Object.values(snapshot)) {
|
||||
expect(work.worktrees.every((id) => visible.includes(id))).toBe(true)
|
||||
expect(work.calls).toBeLessThanOrEqual(visible.length)
|
||||
}
|
||||
}
|
||||
// Only the selected worktree binds an upstream ref; sections never add resolutions.
|
||||
expect(probes.upstreamRef).not.toHaveBeenCalled()
|
||||
counts.push({ atMount, intervals })
|
||||
await act(async () => root.render(null))
|
||||
}
|
||||
// Ten times the attached worktrees costs the same when the same sections are on screen.
|
||||
expect(counts[1]).toEqual(counts[0])
|
||||
})
|
||||
|
||||
it.each([3, 30])(
|
||||
'freezes collapsed and off-screen bodies among %i sections on unrelated app writes',
|
||||
async (count) => {
|
||||
const worktrees = setup(count)
|
||||
const [active, collapsed, offScreen] = worktrees
|
||||
for (const row of worktrees) {
|
||||
probes.visible.add(row.id)
|
||||
}
|
||||
await render(<FolderWorkspaceSourceControlPanel />)
|
||||
const collapsedSection = container.querySelector(
|
||||
`[data-source-control-worktree="${collapsed!.id}"]`
|
||||
)!
|
||||
await act(async () =>
|
||||
collapsedSection.querySelector<HTMLButtonElement>('button[aria-expanded]')!.click()
|
||||
)
|
||||
await scrollTo(worktrees.filter((row) => row !== offScreen).map((row) => row.id))
|
||||
const writes = [
|
||||
() =>
|
||||
useAppStore.setState((state) => ({
|
||||
worktreesByRepo: {
|
||||
repo: state.worktreesByRepo.repo!.map((row) =>
|
||||
row.id === collapsed!.id ? { ...row, lastActivityAt: 99 } : row
|
||||
)
|
||||
}
|
||||
})),
|
||||
() =>
|
||||
useAppStore.setState((state) => ({
|
||||
settings: {
|
||||
...state.settings!,
|
||||
theme: state.settings?.theme === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
})),
|
||||
() =>
|
||||
useAppStore.setState((state) => ({
|
||||
activeGroupIdByWorktree: {
|
||||
...state.activeGroupIdByWorktree,
|
||||
'folder:feature': 'other-group'
|
||||
},
|
||||
activeTabTypeByWorktree: {
|
||||
...state.activeTabTypeByWorktree,
|
||||
'folder:feature': 'terminal'
|
||||
}
|
||||
})),
|
||||
() =>
|
||||
useAppStore.setState((state) => ({
|
||||
groupsByWorktree: {
|
||||
...state.groupsByWorktree,
|
||||
'folder:feature': [
|
||||
{ id: 'other-group', worktreeId: 'folder:feature', tabOrder: [], activeTabId: null }
|
||||
]
|
||||
}
|
||||
})),
|
||||
() =>
|
||||
useAppStore.setState((state) => ({
|
||||
openFiles: [
|
||||
...state.openFiles,
|
||||
{
|
||||
id: 'unrelated-file',
|
||||
worktreeId: 'elsewhere',
|
||||
filePath: '/elsewhere/other.ts',
|
||||
relativePath: 'other.ts',
|
||||
language: 'typescript',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
}
|
||||
]
|
||||
})),
|
||||
() =>
|
||||
useAppStore.setState((state) => ({
|
||||
gitStatusByWorktree: {
|
||||
...state.gitStatusByWorktree,
|
||||
elsewhere: [{ path: 'other.ts', status: 'modified', area: 'unstaged' }]
|
||||
}
|
||||
})),
|
||||
writeUnrelatedGeneration
|
||||
]
|
||||
for (const [writeIndex, write] of writes.entries()) {
|
||||
probes.bodyRenders.clear()
|
||||
probes.eligibility.mockClear()
|
||||
await act(async () => write())
|
||||
for (const row of worktrees) {
|
||||
expect(probes.bodyRenders.get(row.id) ?? 0, `write ${writeIndex}, owner ${row.id}`).toBe(0)
|
||||
}
|
||||
expect(probes.eligibility).not.toHaveBeenCalled()
|
||||
}
|
||||
probes.bodyRenders.clear()
|
||||
await act(async () =>
|
||||
useAppStore.setState((state) => ({
|
||||
openFiles: [
|
||||
...state.openFiles,
|
||||
{
|
||||
id: 'active-file',
|
||||
worktreeId: active!.id,
|
||||
filePath: `${active!.path}/a.ts`,
|
||||
relativePath: 'a.ts',
|
||||
language: 'typescript',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
}
|
||||
],
|
||||
activeFileIdByWorktree: {
|
||||
...state.activeFileIdByWorktree,
|
||||
'folder:feature': 'active-file'
|
||||
},
|
||||
activeTabTypeByWorktree: { ...state.activeTabTypeByWorktree, 'folder:feature': 'editor' }
|
||||
}))
|
||||
)
|
||||
expect(
|
||||
container
|
||||
.querySelector(
|
||||
`[data-source-control-worktree="${active!.id}"] [data-source-control-path="a.ts"]`
|
||||
)
|
||||
?.getAttribute('data-current')
|
||||
).toBe('true')
|
||||
expect(probes.bodyRenders.get(active!.id)).toBeGreaterThan(0)
|
||||
for (const row of worktrees.slice(1)) {
|
||||
expect(probes.bodyRenders.get(row.id) ?? 0).toBe(0)
|
||||
}
|
||||
// The frozen views ignore their own status and owner changes, while a relevant live update is visible.
|
||||
probes.bodyRenders.clear()
|
||||
await act(async () =>
|
||||
useAppStore.setState((state) => ({
|
||||
gitStatusByWorktree: {
|
||||
...state.gitStatusByWorktree,
|
||||
[active!.id]: [
|
||||
...state.gitStatusByWorktree[active!.id]!,
|
||||
{ path: 'visible.ts', status: 'modified', area: 'unstaged' }
|
||||
],
|
||||
[offScreen!.id]: [
|
||||
...state.gitStatusByWorktree[offScreen!.id]!,
|
||||
{ path: 'hidden.ts', status: 'modified', area: 'unstaged' }
|
||||
]
|
||||
}
|
||||
}))
|
||||
)
|
||||
expect(probes.bodyRenders.get(active!.id)).toBeGreaterThan(0)
|
||||
expect(probes.bodyRenders.get(collapsed!.id) ?? 0).toBe(0)
|
||||
expect(probes.bodyRenders.get(offScreen!.id) ?? 0).toBe(0)
|
||||
expect(
|
||||
container.querySelector(`[data-source-control-worktree="${active!.id}"]`)!.textContent
|
||||
).toContain('visible.ts')
|
||||
await scrollTo(worktrees.map((row) => row.id))
|
||||
expect(
|
||||
container.querySelector(`[data-source-control-worktree="${offScreen!.id}"]`)!.textContent
|
||||
).toContain('hidden.ts')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the selected workspace panel idle when another worktree writes generation records', async () => {
|
||||
const [worktree] = setup(1)
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: worktree!.id,
|
||||
activeWorkspaceKey: `worktree:${worktree!.id}`
|
||||
})
|
||||
await render(<SourceControlPanel />)
|
||||
expect(probes.eligibility).toHaveBeenCalled()
|
||||
probes.eligibility.mockClear()
|
||||
renders = 0
|
||||
await act(async () => writeUnrelatedGeneration())
|
||||
expect(probes.eligibility).not.toHaveBeenCalled()
|
||||
expect(renders).toBe(0)
|
||||
})
|
||||
|
||||
it.each(['off-screen', 'collapsed'])(
|
||||
'keeps selection and draft while %s and resumes the current owner snapshot',
|
||||
async (inactive) => {
|
||||
|
||||
@@ -158,79 +158,6 @@ async function render(visible = true) {
|
||||
)
|
||||
}
|
||||
|
||||
it.each([3, 30])(
|
||||
'bounds observation, watches and unrelated renders for %i attached worktrees',
|
||||
async (count) => {
|
||||
const worktrees = setup(count)
|
||||
await render()
|
||||
// One status per visible repository is necessary for its badge; collapsed bodies do no compare work.
|
||||
expect(probes.status).toHaveBeenCalledTimes(count)
|
||||
expect(probes.body).not.toHaveBeenCalled()
|
||||
// Every mounted section watches its tree, collapsed ones included (R4).
|
||||
expect(probes.watches).toEqual(worktrees.map((row) => row.id))
|
||||
probes.header.mockClear()
|
||||
await act(async () => useAppStore.setState({ activeFileId: 'unrelated-file' }))
|
||||
expect(probes.header).not.toHaveBeenCalled()
|
||||
await act(async () =>
|
||||
useAppStore.setState({
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'other-host',
|
||||
{ targetId: 'other-host', status: 'connected', error: null, reconnectAttempt: 0 }
|
||||
]
|
||||
])
|
||||
})
|
||||
)
|
||||
expect(probes.header).not.toHaveBeenCalled()
|
||||
await act(async () =>
|
||||
useAppStore.setState((state) => ({
|
||||
gitStatusByWorktree: {
|
||||
...state.gitStatusByWorktree,
|
||||
[worktrees[0]!.id]: [{ path: 'a.ts', status: 'modified', area: 'unstaged' }]
|
||||
}
|
||||
}))
|
||||
)
|
||||
expect(probes.header.mock.calls).toEqual([['feature-0']])
|
||||
expect(probes.body).not.toHaveBeenCalled()
|
||||
// Agent changes wake the matching collapsed badge, using the existing scheduler's coalescing.
|
||||
probes.status.mockClear()
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, {
|
||||
detail: {
|
||||
runtimeEnvironmentId: null,
|
||||
payload: {
|
||||
worktreePath: worktrees[0]!.path,
|
||||
events: [{ absolutePath: `${worktrees[0]!.path}/a.ts`, kind: 'change' }]
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
})
|
||||
expect(probes.status).toHaveBeenCalledTimes(1)
|
||||
expect(probes.status.mock.calls[0]?.[0]?.worktreeId).toBe(worktrees[0]!.id)
|
||||
const toggles = container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')
|
||||
for (const toggle of toggles) {
|
||||
await act(async () => toggle.click())
|
||||
}
|
||||
expect(probes.body).toHaveBeenCalledTimes(count)
|
||||
expect(probes.watches).toEqual(worktrees.map((row) => row.id))
|
||||
await render(false)
|
||||
probes.status.mockClear()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(120000)
|
||||
})
|
||||
expect(probes.status).not.toHaveBeenCalled()
|
||||
expect(probes.watches).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
function ActiveStatusObserver() {
|
||||
useWorktreeGitStatusObservation(null)
|
||||
return null
|
||||
}
|
||||
|
||||
function ScopedStatusObserver({ owner }: { owner: SourceControlScope }) {
|
||||
useWorktreeGitStatusObservation(owner, { enabled: true })
|
||||
return null
|
||||
@@ -272,48 +199,6 @@ it('filters file events by the section runtime host when a same-id local twin ex
|
||||
}
|
||||
})
|
||||
|
||||
it('refreshes an open diff after a file event with the sidebar closed', async () => {
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mac')
|
||||
const [worktree] = setup(1)
|
||||
const path = '/Users/test/Library/Containers/app/worktree'
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: worktree!.id,
|
||||
activeWorkspaceKey: `worktree:${worktree!.id}`,
|
||||
worktreesByRepo: { repo: [{ ...worktree!, path }] },
|
||||
rightSidebarOpen: false,
|
||||
openFiles: [
|
||||
{
|
||||
id: 'open-diff',
|
||||
filePath: `${path}/a.ts`,
|
||||
relativePath: 'a.ts',
|
||||
worktreeId: worktree!.id,
|
||||
language: 'typescript',
|
||||
isDirty: false,
|
||||
mode: 'diff'
|
||||
}
|
||||
]
|
||||
})
|
||||
await act(async () => root.render(<ActiveStatusObserver />))
|
||||
expect(probes.status).toHaveBeenCalledTimes(1)
|
||||
probes.status.mockClear()
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, {
|
||||
detail: {
|
||||
runtimeEnvironmentId: null,
|
||||
payload: {
|
||||
worktreePath: path,
|
||||
events: [{ absolutePath: `${path}/a.ts`, kind: 'change' }]
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
})
|
||||
expect(probes.status).toHaveBeenCalledTimes(1)
|
||||
expect(probes.status.mock.calls[0]?.[0]?.worktreeId).toBe(worktree!.id)
|
||||
})
|
||||
|
||||
it('includes a paired-runtime descendant of a local attached worktree as a section', async () => {
|
||||
const [parent] = setup(1)
|
||||
const remote = {
|
||||
|
||||
-65
@@ -274,68 +274,3 @@ it.each(['explicit', 'repository'] as const)(
|
||||
expect(stream!.unsubscribe).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps ordinary warm watches, shares roots and releases only section-owned ones', async () => {
|
||||
const children = Array.from({ length: 30 }, (_, i) => worktreeRow('repo', `/child-${i}`, i))
|
||||
const others = [1, 2].map((i) => worktreeRow('other', `/other-${i}`, i))
|
||||
const openFile = (worktree: Worktree) => ({
|
||||
id: `${worktree.id}::file`,
|
||||
filePath: `${worktree.path}/file.ts`,
|
||||
relativePath: 'file.ts',
|
||||
worktreeId: worktree.id,
|
||||
language: 'typescript',
|
||||
isDirty: false,
|
||||
mode: 'edit' as const
|
||||
})
|
||||
setup({
|
||||
children,
|
||||
others,
|
||||
openFiles: [...others, children[0]!].map(openFile),
|
||||
// The three recently used workspaces: two ordinary ones and one that is also a section.
|
||||
lastVisitedAtByWorktreeId: Object.fromEntries(
|
||||
[...others, children[0]!].map((row, i) => [row.id, 100 + i])
|
||||
)
|
||||
})
|
||||
await render()
|
||||
const watched = watchedPaths(watchWorktree.mock.calls)
|
||||
// Thirty sections never take the recently used workspaces' slots.
|
||||
for (const row of others) {
|
||||
expect(watched).toContain(row.path)
|
||||
}
|
||||
// The section and an open file in the same worktree share one root.
|
||||
expect(watched.filter((path) => path === children[0]!.path)).toHaveLength(1)
|
||||
expect(unwatchWorktree).not.toHaveBeenCalled()
|
||||
|
||||
await render(false)
|
||||
const released = watchedPaths(unwatchWorktree.mock.calls).sort()
|
||||
expect(released).toEqual(
|
||||
children
|
||||
.slice(1)
|
||||
.map((row) => row.path)
|
||||
.sort()
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['direct', 'nested'] as const)(
|
||||
'does not claim a watch for unserved %s SSH',
|
||||
async (route) => {
|
||||
const child = worktreeRow('rrepo', '/remote/child', 0, { hostId: 'ssh:nested-ssh' })
|
||||
setup({ children: [child] })
|
||||
useAppStore.setState({
|
||||
repos: useAppStore.getState().repos.map((repo) =>
|
||||
repo.id === 'rrepo'
|
||||
? {
|
||||
...repo,
|
||||
connectionId: 'nested-ssh',
|
||||
executionHostId: route === 'nested' ? 'runtime:env-1' : 'ssh:nested-ssh'
|
||||
}
|
||||
: repo
|
||||
)
|
||||
})
|
||||
await render()
|
||||
expect(watchWorktree).not.toHaveBeenCalled()
|
||||
expect(runtimeStreams).toEqual([])
|
||||
// Scheduling is preserved; unsupported SSH status still reaches the original remote rejection boundary.
|
||||
expect(probes.status.mock.calls.map(([request]) => request.worktreeId)).toEqual([child.id])
|
||||
}
|
||||
)
|
||||
|
||||
-38
@@ -244,42 +244,4 @@ describe('Checks comment edit/delete ownership', () => {
|
||||
)
|
||||
expect(comments().map((c) => c.id)).toEqual([9001, 9002])
|
||||
})
|
||||
|
||||
it('reports a GitHub failure from the owning server without changing the list', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { type: 'permission_denied', message: 'Resource not accessible by integration' }
|
||||
})
|
||||
const { hook, comments } = render({
|
||||
repo: repoRow({ executionHostId: 'runtime:owner-server' })
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await expect(hook.result.current.handleEditComment(COMMENT, 'new body')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Resource not accessible by integration')
|
||||
expect(comments().map((c) => c.body)).toEqual(['old body', 'other'])
|
||||
})
|
||||
|
||||
it('drops a result that lands after the panel moved to another review', async () => {
|
||||
let finish: (value: { ok: false; error: { message: string } }) => void = () => {}
|
||||
mocks.callRuntimeRpc.mockReturnValue(new Promise((resolve) => (finish = resolve)))
|
||||
const { hook, comments, moveAway } = render({
|
||||
repo: repoRow({ executionHostId: 'runtime:owner-server' })
|
||||
})
|
||||
|
||||
let pending: Promise<boolean> = Promise.resolve(true)
|
||||
act(() => {
|
||||
pending = hook.result.current.handleEditComment(COMMENT, 'new body')
|
||||
})
|
||||
moveAway()
|
||||
await act(async () => {
|
||||
finish({ ok: false, error: { message: 'stale failure' } })
|
||||
await pending
|
||||
})
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(comments().map((c) => c.body)).toEqual(['old body', 'other'])
|
||||
})
|
||||
})
|
||||
|
||||
-17
@@ -90,14 +90,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('usePluginDeclarativeState', () => {
|
||||
it('stores a write made before a workspace switch under the workspace it was made in', async () => {
|
||||
const panel = await mountReadyPanel()
|
||||
await panel.switchTo('wt-b')
|
||||
// The guest flushes the edit it made in A as the switch's hydrate reaches it.
|
||||
panel.persist({ hydration: panel.first, value: 'typed in A' })
|
||||
expect(panel.writes()).toEqual([expect.objectContaining({ workspace: 'wt-a' })])
|
||||
})
|
||||
|
||||
it('keeps each write with its own workspace across a quick A, B, A switch', async () => {
|
||||
const panel = await mountReadyPanel()
|
||||
const inB = await panel.switchTo('wt-b')
|
||||
@@ -131,13 +123,4 @@ describe('usePluginDeclarativeState', () => {
|
||||
panel.persist({ hydration: latest + 1, value: 'never sent' })
|
||||
expect(panel.stored()).toEqual([['wt-b', 'eight back']])
|
||||
})
|
||||
|
||||
it('still stores plugin-scope entries tagged with an earlier hydration', async () => {
|
||||
const panel = await mountReadyPanel()
|
||||
await panel.switchTo('wt-b')
|
||||
panel.persist({ hydration: panel.first, scope: 'plugin', value: 'global' })
|
||||
expect(panel.writes()).toEqual([
|
||||
expect.objectContaining({ entries: [expect.objectContaining({ scope: 'plugin' })] })
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
-11
@@ -53,17 +53,6 @@ describe('showSourceControlEntryFailureToast', () => {
|
||||
storeState.activeWorktreeId = 'wt-1'
|
||||
})
|
||||
|
||||
it('still reports a failure belonging to a worktree the user has left, naming it', () => {
|
||||
// Suppress stale recovery actions after switches, but still report the original workspace's failure.
|
||||
storeState.activeWorktreeId = 'wt-2'
|
||||
show({ worktreeId: 'wt-1', worktreeName: 'feature-a', onRetry: vi.fn() })
|
||||
|
||||
expect(toastError).toHaveBeenCalledTimes(1)
|
||||
expect(lastToast().title).toBe('Failed to stage “src/app.ts” in feature-a')
|
||||
expect(lastToast().options.action).toBeUndefined()
|
||||
expect(lastToast().options.duration).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retires a Retry action that became stale after a worktree switch', () => {
|
||||
const onRetry = vi.fn()
|
||||
show({ onRetry })
|
||||
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, useState, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { readStoreListenerCount } from '@/store/store-listener-census'
|
||||
import { useSourceControlStoreActions, type SourceControlStoreActions } from './use-store-actions'
|
||||
|
||||
const originalState = useAppStore.getState()
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function mount(node: ReactNode): void {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => root?.render(node))
|
||||
}
|
||||
|
||||
function unmount(): void {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
}
|
||||
|
||||
function listenerCount(): number {
|
||||
const count = readStoreListenerCount()
|
||||
if (count === null) {
|
||||
throw new Error('store listener census unavailable')
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
unmount()
|
||||
useAppStore.setState(originalState, true)
|
||||
})
|
||||
|
||||
describe('useSourceControlStoreActions store subscriptions', () => {
|
||||
it('subscribes to no store slices', () => {
|
||||
const baseline = listenerCount()
|
||||
|
||||
function Probe(): null {
|
||||
useSourceControlStoreActions()
|
||||
return null
|
||||
}
|
||||
mount(<Probe />)
|
||||
|
||||
// Fixed-identity actions come from getState(); generation records are selected per owner by their consumers.
|
||||
expect(listenerCount() - baseline).toBe(0)
|
||||
|
||||
unmount()
|
||||
expect(listenerCount()).toBe(baseline)
|
||||
})
|
||||
|
||||
it('returns the same object across an unrelated store write and re-render', () => {
|
||||
let latest: SourceControlStoreActions | null = null
|
||||
let rerender: (() => void) | null = null
|
||||
|
||||
function Probe(): null {
|
||||
const [, setTick] = useState(0)
|
||||
rerender = () => setTick((t) => t + 1)
|
||||
latest = useSourceControlStoreActions()
|
||||
return null
|
||||
}
|
||||
mount(<Probe />)
|
||||
|
||||
const first = latest
|
||||
expect(first).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
useAppStore.setState({
|
||||
rightSidebarOpen: !originalState.rightSidebarOpen
|
||||
})
|
||||
})
|
||||
act(() => rerender?.())
|
||||
|
||||
expect(latest).toBe(first)
|
||||
})
|
||||
})
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { makeRepo, makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures'
|
||||
import { SourceControlScopeProvider } from '../panel/worktree-scope'
|
||||
import { useSourceControlNoteOpening } from './use-note-opening'
|
||||
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState(initialAppState, true)
|
||||
})
|
||||
|
||||
it('opens a folder section note in the folder group while the child keeps the content', () => {
|
||||
const openFile = vi.fn<AppState['openFile']>(() => 'file')
|
||||
useAppStore.setState({
|
||||
openFile,
|
||||
activeWorktreeId: 'folder:feature',
|
||||
activeGroupIdByWorktree: { 'folder:feature': 'folder-group' }
|
||||
})
|
||||
const worktree = makeWorktree('wt-child', 'wt-child', { instanceId: 'instance-1' })
|
||||
const owner = {
|
||||
worktreeId: 'wt-child',
|
||||
repoId: 'repo-1',
|
||||
executionHostId: 'ssh:box' as const,
|
||||
worktree,
|
||||
attachedAt: 1,
|
||||
repo: { ...makeRepo(), connectionId: 'box' },
|
||||
placementWorkspaceId: 'folder:feature'
|
||||
}
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSourceControlNoteOpening({
|
||||
activeWorktreeId: 'wt-child',
|
||||
worktreePath: '/tmp/wt-child',
|
||||
entries: [],
|
||||
branchEntries: [],
|
||||
branchSummary: null,
|
||||
handleOpenDiff: vi.fn(),
|
||||
openCommittedDiff: vi.fn(),
|
||||
sourceControlRef: { current: null }
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<SourceControlScopeProvider owner={owner}>{children}</SourceControlScopeProvider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
result.current.handleOpenComment({
|
||||
id: 'note-1',
|
||||
worktreeId: 'wt-child',
|
||||
filePath: 'README.md',
|
||||
source: 'markdown',
|
||||
lineNumber: 3,
|
||||
body: 'check',
|
||||
createdAt: 1,
|
||||
side: 'modified'
|
||||
})
|
||||
|
||||
expect(openFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktreeId: 'wt-child', filePath: '/tmp/wt-child/README.md' }),
|
||||
{
|
||||
placementWorkspaceId: 'folder:feature',
|
||||
executionHostId: 'ssh:box',
|
||||
targetGroupId: 'folder-group'
|
||||
}
|
||||
)
|
||||
})
|
||||
-95
@@ -5,9 +5,6 @@ import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { useSourceControlHostedReviewCreated } from '../review/use-hosted-review-created'
|
||||
import { useSourceControlStoreActions } from '../listing/use-store-actions'
|
||||
import { makeRepo, makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { useSourceControlCommitAction } from '../commit/use-commit-action'
|
||||
@@ -207,95 +204,3 @@ it('retires only the removed host owner on authoritative purge and fences late d
|
||||
.purgeWorktreeTerminalState([hostWorktreeTarget({ id: 'wt-removed', hostId: 'local' })])
|
||||
expect(getWorktreeOperationStore('wt-removed', 'local', 'instance-1').getState().draft).toBe('')
|
||||
})
|
||||
|
||||
it('writes the base ref and opens aggregate diffs on the section host while the folder stays active', async () => {
|
||||
const updateRepo = vi.fn<AppState['updateRepo']>()
|
||||
const openAllDiffs = vi.fn<AppState['openAllDiffs']>()
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: 'folder:feature',
|
||||
activeWorkspaceExecutionHostId: 'local',
|
||||
updateRepo,
|
||||
openAllDiffs
|
||||
})
|
||||
const section = renderHook(() => useSourceControlStoreActions(), {
|
||||
wrapper: sectionWrapper('wt-twin', 'ssh:box')
|
||||
})
|
||||
await section.result.current.updateRepo('repo-1', { worktreeBaseRef: 'release' })
|
||||
expect(updateRepo).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
{ worktreeBaseRef: 'release' },
|
||||
{ hostId: 'ssh:box' }
|
||||
)
|
||||
section.result.current.openAllDiffs('wt-twin', '/remote/repo')
|
||||
expect(openAllDiffs).toHaveBeenCalledWith(
|
||||
'wt-twin',
|
||||
'/remote/repo',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
placementWorkspaceId: 'folder:feature',
|
||||
runtimeEnvironmentId: null,
|
||||
executionHostId: 'ssh:box'
|
||||
}
|
||||
)
|
||||
expect(useAppStore.getState().activeWorktreeId).toBe('folder:feature')
|
||||
})
|
||||
|
||||
it('opens a section file here and its checks in the folder overview without changing workspace', () => {
|
||||
const openFile = vi.fn<AppState['openFile']>(() => 'file')
|
||||
const revealInExplorer = vi.fn<AppState['revealInExplorer']>()
|
||||
const setRightSidebarOpen = vi.fn<AppState['setRightSidebarOpen']>()
|
||||
const setRightSidebarTab = vi.fn<AppState['setRightSidebarTab']>()
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: 'folder:feature',
|
||||
activeGroupIdByWorktree: { 'folder:feature': 'folder-group' },
|
||||
openFile,
|
||||
revealInExplorer,
|
||||
setRightSidebarOpen,
|
||||
setRightSidebarTab
|
||||
})
|
||||
const section = renderHook(
|
||||
() => {
|
||||
const actions = useSourceControlStoreActions()
|
||||
const checks = useSourceControlHostedReviewCreated({
|
||||
activeRepo: null,
|
||||
activeWorktreeId: 'wt-child',
|
||||
branchName: 'feature',
|
||||
fallbackGitHubPRNumber: null,
|
||||
linkedAzureDevOpsPR: null,
|
||||
linkedBitbucketPR: null,
|
||||
linkedGitHubPR: 123,
|
||||
linkedGitLabMR: null,
|
||||
linkedGiteaPR: null,
|
||||
fetchHostedReviewForBranch: actions.fetchHostedReviewForBranch,
|
||||
fetchPRForBranch: actions.fetchPRForBranch,
|
||||
refreshActiveGitStatusAfterMutation: async () => {},
|
||||
setRightSidebarOpen: actions.setRightSidebarOpen,
|
||||
setRightSidebarTab: actions.setRightSidebarTab,
|
||||
updateWorktreeMeta: actions.updateWorktreeMeta
|
||||
})
|
||||
return { actions, checks }
|
||||
},
|
||||
{ wrapper: sectionWrapper('wt-child', 'ssh:box') }
|
||||
)
|
||||
section.result.current.actions.revealInExplorer('wt-child', '/remote/repo/a.ts')
|
||||
expect(openFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/remote/repo/a.ts',
|
||||
worktreeId: 'wt-child',
|
||||
mode: 'edit'
|
||||
}),
|
||||
{
|
||||
placementWorkspaceId: 'folder:feature',
|
||||
executionHostId: 'ssh:box',
|
||||
runtimeEnvironmentId: null,
|
||||
targetGroupId: 'folder-group',
|
||||
preview: false
|
||||
}
|
||||
)
|
||||
expect(revealInExplorer).not.toHaveBeenCalled()
|
||||
section.result.current.checks.openHostedReviewInChecks()
|
||||
expect(setRightSidebarTab).toHaveBeenCalledWith('pr-checks')
|
||||
expect(useAppStore.getState().activeWorktreeId).toBe('folder:feature')
|
||||
})
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
noteUserWorkspaceSwitch,
|
||||
resetWorkspaceSwitchUserActsForTests
|
||||
} from '@/lib/workspace-switch-user-act'
|
||||
|
||||
const getRuntimeGitHistoryMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/runtime/source-control/git/runtime-git-status-client', () => ({
|
||||
getRuntimeGitHistory: getRuntimeGitHistoryMock
|
||||
}))
|
||||
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
|
||||
|
||||
import { useSourceControlGitHistory } from './use-git-history'
|
||||
|
||||
type Props = Parameters<typeof useSourceControlGitHistory>[0]
|
||||
|
||||
const props = (overrides: Partial<Props> = {}): Props => ({
|
||||
activeRepoTarget: { kind: 'local' },
|
||||
activeWorktreeId: 'wt-1',
|
||||
worktreePath: '\\\\wsl.localhost\\Ubuntu\\repo',
|
||||
branchName: 'main',
|
||||
compareBaseRef: 'main',
|
||||
isFolder: false,
|
||||
isBranchVisible: true,
|
||||
isGitHistoryExpanded: true,
|
||||
isGitHistoryVisible: true,
|
||||
worktreeMap: new Map([
|
||||
['wt-1', {}],
|
||||
['wt-2', {}]
|
||||
]),
|
||||
...overrides
|
||||
})
|
||||
|
||||
const wakes = (): unknown[] =>
|
||||
getRuntimeGitHistoryMock.mock.calls.map((call) => call[1].wakeWslDistro)
|
||||
|
||||
beforeEach(() => {
|
||||
getRuntimeGitHistoryMock.mockReset().mockResolvedValue({ items: [] })
|
||||
resetWorkspaceSwitchUserActsForTests()
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
// E9: only the user's own act may start a stopped WSL distro for a Commits read.
|
||||
describe('Commits history and a stopped WSL distro', () => {
|
||||
it('stays background on a mount or remount with Commits already open', async () => {
|
||||
const first = renderHook(() => useSourceControlGitHistory(props()))
|
||||
await act(async () => {})
|
||||
first.unmount()
|
||||
renderHook(() => useSourceControlGitHistory(props()))
|
||||
await act(async () => {})
|
||||
expect(wakes()).toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
it('wakes once for the user switching to the workspace, never again on a remount', async () => {
|
||||
noteUserWorkspaceSwitch('wt-1')
|
||||
const first = renderHook(() => useSourceControlGitHistory(props()))
|
||||
await act(async () => {})
|
||||
first.unmount()
|
||||
renderHook(() => useSourceControlGitHistory(props()))
|
||||
await act(async () => {})
|
||||
expect(wakes()).toEqual([true, undefined])
|
||||
})
|
||||
|
||||
it('wakes when the user expands Commits, not when Orca re-shows it or a refresh follows', async () => {
|
||||
const { result, rerender } = renderHook((p: Props) => useSourceControlGitHistory(p), {
|
||||
initialProps: props({ isGitHistoryExpanded: false })
|
||||
})
|
||||
await act(async () => {})
|
||||
rerender(props())
|
||||
await act(async () => {})
|
||||
// Hidden and shown again by Orca (a sidebar re-show), with no switch.
|
||||
rerender(props({ isGitHistoryVisible: false }))
|
||||
rerender(props())
|
||||
await act(async () => {})
|
||||
await act(async () => result.current.refreshGitHistoryRef.current())
|
||||
expect(wakes()).toEqual([true, undefined, undefined])
|
||||
})
|
||||
|
||||
it("does not take another worktree's restored expanded view as a click", async () => {
|
||||
const { rerender } = renderHook((p: Props) => useSourceControlGitHistory(p), {
|
||||
initialProps: props({ isGitHistoryExpanded: false })
|
||||
})
|
||||
await act(async () => {})
|
||||
rerender(props({ activeWorktreeId: 'wt-2' }))
|
||||
await act(async () => {})
|
||||
expect(wakes()).toEqual([undefined])
|
||||
})
|
||||
|
||||
it("keeps the Refresh button's explicit wake", async () => {
|
||||
const { result } = renderHook(() => useSourceControlGitHistory(props()))
|
||||
await act(async () => {})
|
||||
await act(async () => result.current.refreshGitHistory({ wakeWslDistro: true }))
|
||||
expect(wakes()).toEqual([undefined, true])
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,6 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import CommentMarkdown from './CommentMarkdown'
|
||||
import type { DocumentCodeBlockRenderer } from './comment-markdown-element-renderers'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mermaid: vi.fn(() => {
|
||||
@@ -18,88 +17,7 @@ afterEach(() => {
|
||||
mocks.mermaid.mockClear()
|
||||
})
|
||||
|
||||
/** Ignore inter-block line breaks because they have no rendered layout. */
|
||||
function blocksOf(container: HTMLElement): string[] {
|
||||
return Array.from(container.firstElementChild?.children ?? [], (block) => block.outerHTML)
|
||||
}
|
||||
|
||||
const REPLY = [
|
||||
'First paragraph with **bold** and a [link](https://example.com).',
|
||||
'',
|
||||
'```ts',
|
||||
'const a = 1',
|
||||
'```',
|
||||
'',
|
||||
'- one',
|
||||
'- two',
|
||||
'',
|
||||
'| a | b |',
|
||||
'| - | - |',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
'Last paragraph.'
|
||||
].join('\n')
|
||||
|
||||
describe('growing comment markdown', () => {
|
||||
it('renders a streamed reply to the same document as the finished one', () => {
|
||||
const whole = render(<CommentMarkdown content={REPLY} variant="document" />)
|
||||
const expected = blocksOf(whole.container)
|
||||
whole.unmount()
|
||||
|
||||
const streamed = render(<CommentMarkdown content="" variant="document" growing />)
|
||||
for (let end = 5; end < REPLY.length + 5; end += 5) {
|
||||
streamed.rerender(
|
||||
<CommentMarkdown content={REPLY.slice(0, end)} variant="document" growing />
|
||||
)
|
||||
}
|
||||
streamed.rerender(<CommentMarkdown content={REPLY} variant="document" />)
|
||||
expect(blocksOf(streamed.container)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('does not render a finished block again as later text arrives', () => {
|
||||
const renderCodeBlock = vi.fn<DocumentCodeBlockRenderer>(({ children }) => (
|
||||
<pre>{children}</pre>
|
||||
))
|
||||
const fence = '```ts\nconst a = 1\n```\n\n'
|
||||
const view = render(
|
||||
<CommentMarkdown
|
||||
content={`${fence}Next`}
|
||||
variant="document"
|
||||
renderCodeBlock={renderCodeBlock}
|
||||
growing
|
||||
/>
|
||||
)
|
||||
const afterFence = renderCodeBlock.mock.calls.length
|
||||
expect(afterFence).toBeGreaterThan(0)
|
||||
|
||||
let content = `${fence}Next`
|
||||
for (const more of [' paragraph', ' keeps', ' growing.\n\nAnd another.']) {
|
||||
content += more
|
||||
view.rerender(
|
||||
<CommentMarkdown
|
||||
content={content}
|
||||
variant="document"
|
||||
renderCodeBlock={renderCodeBlock}
|
||||
growing
|
||||
/>
|
||||
)
|
||||
}
|
||||
expect(renderCodeBlock.mock.calls.length).toBe(afterFence)
|
||||
})
|
||||
|
||||
it('does not remount earlier text when a kept tag closes', () => {
|
||||
const props = { variant: 'document', unknownHtmlAsText: true, growing: true } as const
|
||||
const view = render(<CommentMarkdown content={'Intro.\n\nPress <kbd>Ctrl'} {...props} />)
|
||||
const press = view.container.querySelectorAll('p')[1]
|
||||
for (const content of [
|
||||
'Intro.\n\nPress <kbd>Ctrl</kbd> to copy.',
|
||||
'Intro.\n\nPress <kbd>Ctrl</kbd> to copy.\n\nTail.'
|
||||
]) {
|
||||
view.rerender(<CommentMarkdown content={content} {...props} />)
|
||||
expect(view.container.querySelectorAll('p')[1]).toBe(press)
|
||||
}
|
||||
})
|
||||
|
||||
it('links a path in an earlier block once the host confirms it', () => {
|
||||
const content = 'Edited src/a.ts.\n\nThen more text'
|
||||
const view = render(
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resetSystemPrefersDarkSubscriptionForTests } from '@/hooks/theme/use-system-prefers-dark'
|
||||
|
||||
const mermaidProps: { current: { isDark: boolean } | null } = vi.hoisted(() => ({ current: null }))
|
||||
|
||||
vi.mock('@/components/editor/MermaidBlock', () => ({
|
||||
default: (props: { isDark: boolean }) => {
|
||||
mermaidProps.current = props
|
||||
return null
|
||||
}
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({ settings: { theme: 'system' } })
|
||||
}))
|
||||
|
||||
import CommentMermaidBlock from './CommentMermaidBlock'
|
||||
|
||||
const originalMatchMedia = window.matchMedia
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
mermaidProps.current = null
|
||||
resetSystemPrefersDarkSubscriptionForTests()
|
||||
window.matchMedia = originalMatchMedia
|
||||
})
|
||||
|
||||
describe('CommentMermaidBlock system theme', () => {
|
||||
it('follows a system color-scheme change while mounted', () => {
|
||||
let matches = false
|
||||
const listeners = new Set<EventListener>()
|
||||
const media = {
|
||||
get matches() {
|
||||
return matches
|
||||
},
|
||||
addEventListener: (_type: string, listener: EventListener) => listeners.add(listener),
|
||||
removeEventListener: (_type: string, listener: EventListener) => listeners.delete(listener)
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook only reads `matches` and (un)subscribes to `change`.
|
||||
window.matchMedia = (() => media) as unknown as typeof window.matchMedia
|
||||
|
||||
render(<CommentMermaidBlock content="graph TD; A-->B" />)
|
||||
expect(mermaidProps.current?.isDark).toBe(false)
|
||||
|
||||
act(() => {
|
||||
matches = true
|
||||
for (const listener of listeners) {
|
||||
listener(new MediaQueryListEvent('change', { matches: true }))
|
||||
}
|
||||
})
|
||||
expect(mermaidProps.current?.isDark).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { htmlRendersAsText } from './comment-markdown-unknown-html-as-text'
|
||||
import { splitMarkdownTopLevelBlocks, type MarkdownBlockSplit } from './markdown-top-level-blocks'
|
||||
|
||||
const REPLY = [
|
||||
'Here is the plan.',
|
||||
'',
|
||||
'## Steps',
|
||||
'',
|
||||
'- first item',
|
||||
'- second item',
|
||||
'',
|
||||
' continued inside the list',
|
||||
'',
|
||||
'1. one',
|
||||
'2. two',
|
||||
'',
|
||||
'A loose list follows.',
|
||||
'',
|
||||
'1. first',
|
||||
'',
|
||||
'2. second',
|
||||
'',
|
||||
' with a paragraph of its own',
|
||||
'',
|
||||
'3. third',
|
||||
'',
|
||||
'> a quote',
|
||||
'that continues lazily',
|
||||
'',
|
||||
'```ts',
|
||||
'const a = 1',
|
||||
'',
|
||||
'## not a heading',
|
||||
'```',
|
||||
'',
|
||||
'Intro to the table',
|
||||
'| a | b |',
|
||||
'| - | - |',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
'Setext title',
|
||||
'===',
|
||||
'',
|
||||
'Closing words with `code` and **bold**.',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
function streamed(source: string, step: number): string[] {
|
||||
let split: MarkdownBlockSplit | null = null
|
||||
for (let end = step; end < source.length + step; end += step) {
|
||||
split = splitMarkdownTopLevelBlocks(source.slice(0, end), split)
|
||||
}
|
||||
return (split?.blocks ?? []).map((block) => block.text)
|
||||
}
|
||||
|
||||
function cut(source: string, previous: MarkdownBlockSplit | null = null): string[] {
|
||||
return splitMarkdownTopLevelBlocks(source, previous).blocks.map((block) => block.text)
|
||||
}
|
||||
|
||||
describe('splitMarkdownTopLevelBlocks', () => {
|
||||
it.each([1, 3, 7])('reaches the same cut when streamed %i characters at a time', (step) => {
|
||||
expect(streamed(REPLY, step)).toEqual(cut(REPLY))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a link definition', 'See [the docs][docs].\n\nMore.\n\n[docs]: https://example.com\n'],
|
||||
['a footnote', 'A claim.[^1]\n\nMore.\n\n[^1]: The source.\n'],
|
||||
['a nested definition', 'See [the docs][docs].\n\n> [docs]: https://example.com\n'],
|
||||
['raw HTML', 'Before.\n\n<details>\n\nhidden **text**\n\n</details>\n'],
|
||||
['raw HTML inside a quote', 'Before.\n\n> <table><tr><td>cell\n\nAfter.\n'],
|
||||
['an inline tag left open', 'Before <sub>low.\n\nAfter.\n']
|
||||
])('leaves a document with %s whole, however it arrived', (_name, source) => {
|
||||
expect(cut(source)).toEqual([source])
|
||||
expect(streamed(source, 2)).toEqual([source])
|
||||
})
|
||||
|
||||
describe('when unknown HTML renders as text', () => {
|
||||
const rendersAsText = (html: string): boolean =>
|
||||
htmlRendersAsText(html, new Set(['b', 'sub', 'details']))
|
||||
function streamedAsText(source: string): string[] {
|
||||
let split: MarkdownBlockSplit | null = null
|
||||
for (let end = 2; end < source.length + 2; end += 2) {
|
||||
split = splitMarkdownTopLevelBlocks(
|
||||
source.slice(0, end),
|
||||
split,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
rendersAsText
|
||||
)
|
||||
}
|
||||
return (split?.blocks ?? []).map((block) => block.text)
|
||||
}
|
||||
|
||||
it.each([
|
||||
['a type parameter', 'A Vec<T> of Promise<void>.\n\nAfter.\n', 2],
|
||||
['an unknown tag block', '<example>\n\nInside.\n\n</example>\n', 3]
|
||||
])('keeps cutting a document with %s', (_name, source, blocks) => {
|
||||
expect(streamedAsText(source)).toHaveLength(blocks)
|
||||
expect(streamedAsText(source).join('')).toBe(source)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a kept tag', 'Before <sub>low</sub>.\n\nAfter.\n'],
|
||||
['a kept tag beside an unknown one', 'A <example><b>bold</b>.\n\nAfter.\n'],
|
||||
[
|
||||
'bold the HTML parser carries out of a table',
|
||||
'<b><table><tr><td>one</b></td></tr></table>\n\nAfter.\n'
|
||||
],
|
||||
['a comment', '<!-- a --!><b>oops -->\n\nAfter.\n'],
|
||||
['a malformed kept tag', '<details title=a<>\n\nAfter **x**\n']
|
||||
])('leaves a document with %s whole', (_name, source) => {
|
||||
expect(streamedAsText(source)).toEqual([source])
|
||||
})
|
||||
|
||||
it('leaves raw unknown HTML whole when it does not render as text', () => {
|
||||
expect(cut('<select>\n\nAfter **bold**.\n')).toHaveLength(1)
|
||||
expect(cut('A Vec<T>.\n\nAfter.\n')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('starts over when the source is replaced rather than appended to', () => {
|
||||
const first = splitMarkdownTopLevelBlocks('one\n\ntwo\n', null)
|
||||
expect(cut('other\n\ntext\n', first)).toEqual(['other\n\n', 'text\n'])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { ProjectMovePauseResponse } from '../../../../shared/project-move-pause'
|
||||
import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const tabsByWorktree: Record<string, { id: string }[]> = {}
|
||||
@@ -104,32 +103,6 @@ describe('project move renderer completion', () => {
|
||||
expect(f.responses.map((response) => response.phase)).toEqual(['prepare', 'persist'])
|
||||
})
|
||||
|
||||
it('resolves every twin chat host and the terminal owner instead of assuming local', async () => {
|
||||
const f = fixture()
|
||||
const id = 'repo::/wt'
|
||||
mocks.state.worktreesByRepo = {
|
||||
repo: [
|
||||
{ id, repoId: 'repo', hostId: 'local' },
|
||||
{ id, repoId: 'repo', hostId: 'runtime:env' }
|
||||
]
|
||||
}
|
||||
mocks.state.tabsByWorktree = { [id]: [{ id: 'tab' }] }
|
||||
mocks.state.ptyIdsByTabId = { tab: [toRemoteRuntimePtyId('pty', 'env')] }
|
||||
await f.prepare([id])
|
||||
expect(f.responses[0]).toEqual({
|
||||
requestId: 'move-1',
|
||||
protocol: 1,
|
||||
phase: 'prepare',
|
||||
targets: [
|
||||
{
|
||||
worktreeId: id,
|
||||
terminalHost: { kind: 'environment', environmentId: 'env' },
|
||||
chatHosts: [{ kind: 'self' }, { kind: 'environment', environmentId: 'env' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses ambiguous twins before starting a sleep', async () => {
|
||||
const f = fixture()
|
||||
const id = 'repo::/wt'
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRCheckDetail } from '../../github/check-types'
|
||||
import { getCheckSeverityRank, sortChecksBySeverity } from './pr-check-severity-order'
|
||||
import { mapGitLabPipelineJobStatusToConclusion } from './gitlab/gitlab-pipeline-checks'
|
||||
|
||||
const check = (name: string, conclusion: string | null) =>
|
||||
({ name, conclusion }) as Pick<PRCheckDetail, 'name' | 'conclusion'>
|
||||
|
||||
describe('PR check severity order', () => {
|
||||
it('orders failures, then in-flight work, then successes', () => {
|
||||
const sorted = sortChecksBySeverity([
|
||||
check('success', 'success'),
|
||||
check('skipped', 'skipped'),
|
||||
check('pending', null),
|
||||
check('cancelled', 'cancelled'),
|
||||
check('failure', 'failure')
|
||||
])
|
||||
|
||||
expect(sorted.map((c) => c.name)).toEqual([
|
||||
'failure',
|
||||
'cancelled',
|
||||
'pending',
|
||||
'success',
|
||||
'skipped'
|
||||
])
|
||||
})
|
||||
|
||||
it('covers every known conclusion and preserves input order within equal ranks', () => {
|
||||
const sorted = sortChecksBySeverity([
|
||||
check('success-a', 'success'),
|
||||
check('unknown', 'future_state'),
|
||||
check('skipped', 'skipped'),
|
||||
check('action-required', 'action_required'),
|
||||
check('pending', 'pending'),
|
||||
check('timed-out', 'timed_out'),
|
||||
check('neutral', 'neutral'),
|
||||
check('cancelled', 'cancelled'),
|
||||
check('failure', 'failure'),
|
||||
check('success-b', 'success'),
|
||||
check('failure-b', 'failure-b')
|
||||
])
|
||||
|
||||
expect(sorted.map((c) => c.name)).toEqual([
|
||||
'action-required',
|
||||
'timed-out',
|
||||
'failure',
|
||||
'cancelled',
|
||||
'pending',
|
||||
'success-a',
|
||||
'success-b',
|
||||
'neutral',
|
||||
'skipped',
|
||||
'unknown',
|
||||
'failure-b'
|
||||
])
|
||||
})
|
||||
|
||||
// Object rank tables expose prototype names as functions, producing NaN comparisons and unstable ordering.
|
||||
it('treats prototype property names as unknown conclusions', () => {
|
||||
for (const inherited of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) {
|
||||
expect(getCheckSeverityRank(inherited)).toBe(
|
||||
getCheckSeverityRank('stale_from_a_future_github')
|
||||
)
|
||||
}
|
||||
|
||||
const sorted = sortChecksBySeverity([
|
||||
check('inherited', 'constructor'),
|
||||
check('failure', 'failure'),
|
||||
check('success', 'success')
|
||||
])
|
||||
expect(sorted.map((c) => c.name)).toEqual(['failure', 'success', 'inherited'])
|
||||
})
|
||||
|
||||
it('treats a missing conclusion as pending', () => {
|
||||
expect(getCheckSeverityRank(null)).toBe(getCheckSeverityRank('pending'))
|
||||
expect(getCheckSeverityRank(undefined)).toBe(getCheckSeverityRank('pending'))
|
||||
})
|
||||
|
||||
it('leaves the input array untouched', () => {
|
||||
const checks = [check('success', 'success'), check('failure', 'failure')]
|
||||
sortChecksBySeverity(checks)
|
||||
expect(checks.map((c) => c.name)).toEqual(['success', 'failure'])
|
||||
})
|
||||
|
||||
it('orders normalized provider states with stable ties', () => {
|
||||
const checks = [
|
||||
// Preserve an unrecognized provider value so it remains visibly unknown.
|
||||
check('future', 'future_state'),
|
||||
check('manual', mapGitLabPipelineJobStatusToConclusion('manual')),
|
||||
check('pass-a', 'success'),
|
||||
check('pass-b', 'success')
|
||||
]
|
||||
|
||||
// A manual GitLab gate is neutral, so it sinks below the passing checks a reviewer reads first.
|
||||
expect(sortChecksBySeverity(checks).map((item) => item.name)).toEqual([
|
||||
'pass-a',
|
||||
'pass-b',
|
||||
'manual',
|
||||
'future'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -233,10 +233,6 @@ describe('buildFixBrokenChecksPrompt', () => {
|
||||
})
|
||||
|
||||
describe('truncateLogTailForPrompt', () => {
|
||||
it('keeps the last prompt log lines and normalizes CRLF', () => {
|
||||
expect(truncateLogTailForPrompt('one\r\ntwo\r\nthree')).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('retains the exact line and code-unit boundaries without adding a byte clamp', () => {
|
||||
expect(PROMPT_LOG_TAIL_LINES).toBe(150)
|
||||
expect(PROMPT_LOG_TAIL_SCAN_CODE_UNITS).toBe(256 * 1024)
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveCreatePrIntentRemoteStep,
|
||||
resolveCreateReviewIntentEligibility,
|
||||
type CreateReviewIntentKind
|
||||
} from './source-control-create-review-intent'
|
||||
import type { HostedReviewCreationBlockedReason } from '../hosted-review/hosted-review-contracts'
|
||||
import type { GitUpstreamStatus } from '../../git/status/git-status-types'
|
||||
|
||||
function unavailableEligibility(blockedReason: HostedReviewCreationBlockedReason) {
|
||||
return {
|
||||
provider: 'github' as const,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason,
|
||||
nextAction: null,
|
||||
defaultBaseRef: 'main',
|
||||
reviewLookupOutcome: 'unavailable' as const
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveCreateReviewIntentEligibility', () => {
|
||||
it('rejects unavailable eligibility when the default branch is unknown', () => {
|
||||
expect(
|
||||
resolveCreateReviewIntentEligibility({
|
||||
stagedCount: 1,
|
||||
hasStageableChanges: true,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
...unavailableEligibility('dirty'),
|
||||
defaultBaseRef: null
|
||||
}
|
||||
})
|
||||
).toEqual({ eligible: false, kind: null })
|
||||
})
|
||||
|
||||
it('stays ineligible when no local blocker remains under unavailable lookup', () => {
|
||||
expect(
|
||||
resolveCreateReviewIntentEligibility({
|
||||
stagedCount: 0,
|
||||
hasStageableChanges: false,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
...unavailableEligibility('dirty'),
|
||||
blockedReason: null
|
||||
}
|
||||
})
|
||||
).toEqual({ eligible: false, kind: null })
|
||||
})
|
||||
|
||||
it('keeps dirty local preparation eligible when review lookup is unavailable', () => {
|
||||
expect(
|
||||
resolveCreateReviewIntentEligibility({
|
||||
stagedCount: 0,
|
||||
hasStageableChanges: true,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: unavailableEligibility('dirty')
|
||||
})
|
||||
).toEqual({ eligible: true, kind: 'dirty' })
|
||||
})
|
||||
|
||||
it('still requires a message before committing staged changes without lookup authority', () => {
|
||||
expect(
|
||||
resolveCreateReviewIntentEligibility({
|
||||
stagedCount: 1,
|
||||
hasStageableChanges: false,
|
||||
hasMessage: false,
|
||||
hasUnresolvedConflicts: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: unavailableEligibility('dirty')
|
||||
})
|
||||
).toEqual({ eligible: true, kind: 'message_required' })
|
||||
})
|
||||
|
||||
it.each<{
|
||||
blockedReason: HostedReviewCreationBlockedReason
|
||||
blockedKind: CreateReviewIntentKind
|
||||
stagedCount?: number
|
||||
hasStageableChanges?: boolean
|
||||
branchCommitsAhead?: number
|
||||
upstreamStatus?: GitUpstreamStatus
|
||||
}>([
|
||||
{
|
||||
blockedReason: 'no_upstream',
|
||||
blockedKind: 'no_upstream',
|
||||
branchCommitsAhead: 1,
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
|
||||
},
|
||||
{
|
||||
blockedReason: 'needs_push',
|
||||
blockedKind: 'needs_push',
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
blockedReason: 'needs_sync',
|
||||
blockedKind: 'needs_sync',
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 0,
|
||||
behind: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
blockedReason: 'needs_sync',
|
||||
blockedKind: 'force_push',
|
||||
branchCommitsAhead: 1,
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
}
|
||||
}
|
||||
])(
|
||||
'keeps recoverable $blockedKind preparation eligible when review lookup is unavailable',
|
||||
({
|
||||
blockedReason,
|
||||
blockedKind,
|
||||
stagedCount = 0,
|
||||
hasStageableChanges = false,
|
||||
branchCommitsAhead,
|
||||
upstreamStatus
|
||||
}) => {
|
||||
expect(
|
||||
resolveCreateReviewIntentEligibility({
|
||||
stagedCount,
|
||||
hasStageableChanges,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
upstreamStatus,
|
||||
hostedReviewCreation: unavailableEligibility(blockedReason),
|
||||
branchCommitsAhead
|
||||
})
|
||||
).toEqual({ eligible: true, kind: blockedKind })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('resolveCreatePrIntentRemoteStep', () => {
|
||||
it('resolves safe remote steps for publish, push, and patch-equivalent force-push', () => {
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
branchCommitsAhead: 2,
|
||||
hasCurrentBranch: true,
|
||||
hostedReviewCreation: { canCreate: false, blockedReason: 'no_upstream' }
|
||||
})
|
||||
).toBe('publish')
|
||||
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hasCurrentBranch: true,
|
||||
hostedReviewCreation: { canCreate: false, blockedReason: 'needs_push' }
|
||||
})
|
||||
).toBe('push')
|
||||
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
ahead: 3,
|
||||
behind: 2,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
},
|
||||
branchCommitsAhead: 3,
|
||||
hasCurrentBranch: true,
|
||||
hostedReviewCreation: { canCreate: false, blockedReason: 'needs_sync' }
|
||||
})
|
||||
).toBe('force_push')
|
||||
})
|
||||
|
||||
it('fast-forwards behind-only branches, blocks diverged and unpublished-without-commits branches', () => {
|
||||
// Diverged branches require an explicit stop; automatic merge would reconcile without consent.
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 1 },
|
||||
hasCurrentBranch: true,
|
||||
hostedReviewCreation: { canCreate: false, blockedReason: 'needs_sync' }
|
||||
})
|
||||
).toBe('blocked')
|
||||
|
||||
// Behind with no local commits: pure --ff-only (never plain merge sync).
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 },
|
||||
hasCurrentBranch: true,
|
||||
hostedReviewCreation: { canCreate: false, blockedReason: 'needs_sync' }
|
||||
})
|
||||
).toBe('fast_forward')
|
||||
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
branchCommitsAhead: 0,
|
||||
hasCurrentBranch: true,
|
||||
hostedReviewCreation: { canCreate: false, blockedReason: 'no_upstream' }
|
||||
})
|
||||
).toBe('blocked')
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRInfo } from '../../github/pull-request-types'
|
||||
import { resolveSourceControlCommitAreaPrimaryActionDecision } from './source-control-primary-action-decision'
|
||||
import type { SourceControlPrimaryActionDecisionInputs } from './source-control-primary-action-decision-types'
|
||||
import {
|
||||
resolveSourceControlNextStep,
|
||||
type SourceControlNextStepInputs
|
||||
} from './source-control-next-step-decision'
|
||||
|
||||
function local(overrides: Partial<SourceControlPrimaryActionDecisionInputs> = {}) {
|
||||
return resolveSourceControlCommitAreaPrimaryActionDecision({
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
hasUnresolvedConflicts: false,
|
||||
isCommitting: false,
|
||||
isRemoteOperationActive: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
const OPEN_PR: PRInfo = {
|
||||
number: 7,
|
||||
title: 'feat',
|
||||
state: 'open',
|
||||
url: 'https://github.com/o/r/pull/7',
|
||||
checksStatus: 'success',
|
||||
updatedAt: '2026-11-01T00:00:00Z',
|
||||
mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: 'CLEAN',
|
||||
reviewDecision: 'APPROVED'
|
||||
}
|
||||
|
||||
function step(overrides: Partial<SourceControlNextStepInputs> = {}, pr: Partial<PRInfo> = {}) {
|
||||
return resolveSourceControlNextStep({
|
||||
local: local(),
|
||||
conflictOperation: null,
|
||||
hasUnresolvedConflicts: false,
|
||||
hasChangesToCommit: false,
|
||||
reviewCreation: 'unavailable',
|
||||
review: { kind: 'github', pr: { ...OPEN_PR, ...pr }, failedChecks: 0, pendingChecks: 0 },
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveSourceControlNextStep', () => {
|
||||
it('walks the branch from local work to merge in order', () => {
|
||||
expect(step({ hasUnresolvedConflicts: true, conflictOperation: 'merge' })).toEqual({
|
||||
kind: 'resolve_conflicts',
|
||||
scope: 'local'
|
||||
})
|
||||
expect(step({ conflictOperation: 'merge' }).kind).toBe('complete_merge')
|
||||
expect(
|
||||
step({
|
||||
hasChangesToCommit: true,
|
||||
local: local({ hasStageableChanges: true, hasUnstagedChanges: true })
|
||||
}).kind
|
||||
).toBe('commit')
|
||||
expect(step({ hasChangesToCommit: true, local: local({ stagedCount: 2 }) }).kind).toBe('commit')
|
||||
// A fetch on a clean branch blocks commits but leaves nothing to commit.
|
||||
expect(
|
||||
step({ local: local({ isRemoteOperationActive: true, inFlightRemoteOpKind: 'fetch' }) }).kind
|
||||
).toBe('merge')
|
||||
expect(
|
||||
step({ local: local({ upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 } }) }).kind
|
||||
).toBe('remote')
|
||||
expect(
|
||||
step(
|
||||
{},
|
||||
{ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY', reviewDecision: 'REVIEW_REQUIRED' }
|
||||
)
|
||||
).toEqual({ kind: 'resolve_conflicts', scope: 'review' })
|
||||
expect(
|
||||
step({ review: { kind: 'github', pr: OPEN_PR, failedChecks: 1, pendingChecks: 0 } })
|
||||
).toEqual({ kind: 'fix_checks', failedChecks: 1 })
|
||||
expect(step({}, { autoMergeEnabled: true }).kind).toBe('auto_merge_on')
|
||||
expect(step().kind).toBe('merge')
|
||||
expect(step({}, { state: 'merged' }).kind).toBe('merged')
|
||||
})
|
||||
|
||||
it('offers Create PR only once nothing local is left, pushing first when the host asks', () => {
|
||||
expect(step({ review: { kind: 'none' }, reviewCreation: 'create' })).toEqual({
|
||||
kind: 'create_review',
|
||||
pushFirst: false
|
||||
})
|
||||
expect(
|
||||
step({
|
||||
local: local({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } }),
|
||||
review: { kind: 'none' },
|
||||
reviewCreation: 'push_and_create'
|
||||
})
|
||||
).toEqual({ kind: 'create_review', pushFirst: true })
|
||||
expect(
|
||||
step({
|
||||
local: local({ stagedCount: 1, hasMessage: true }),
|
||||
hasChangesToCommit: true,
|
||||
review: { kind: 'none' },
|
||||
reviewCreation: 'create'
|
||||
}).kind
|
||||
).toBe('commit')
|
||||
expect(step({ review: { kind: 'none' } }).kind).toBe('none')
|
||||
expect(step({ review: { kind: 'loading' }, reviewCreation: 'create' }).kind).toBe('none')
|
||||
})
|
||||
})
|
||||
@@ -1,145 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveSourceControlCommitAreaPrimaryActionDecision,
|
||||
resolveSourceControlPrimaryActionDecision
|
||||
} from './source-control-primary-action-decision'
|
||||
import type { SourceControlPrimaryActionDecisionInputs } from './source-control-primary-action-decision-types'
|
||||
|
||||
function inputs(
|
||||
overrides: Partial<SourceControlPrimaryActionDecisionInputs> = {}
|
||||
): SourceControlPrimaryActionDecisionInputs {
|
||||
return {
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
hasUnresolvedConflicts: false,
|
||||
isCommitting: false,
|
||||
isRemoteOperationActive: false,
|
||||
upstreamStatus: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('source-control primary action decision', () => {
|
||||
it('returns Stage All for a dirty tree before remote actions', () => {
|
||||
const result = resolveSourceControlCommitAreaPrimaryActionDecision(
|
||||
inputs({
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 4 }
|
||||
})
|
||||
)
|
||||
expect(result.kind).toBe('stage')
|
||||
expect(result.titleIntent).toBe('stage_all_changes')
|
||||
expect(result.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks commits while unresolved conflicts exist', () => {
|
||||
const result = resolveSourceControlCommitAreaPrimaryActionDecision(
|
||||
inputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true })
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
kind: 'commit',
|
||||
titleIntent: 'resolve_conflicts_before_commit',
|
||||
disabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks unpublished branch publishing when HEAD is detached', () => {
|
||||
const result = resolveSourceControlCommitAreaPrimaryActionDecision(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
hasCurrentBranch: false
|
||||
})
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
kind: 'commit',
|
||||
titleIntent: 'checkout_branch_before_publish',
|
||||
disabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('mirrors in-flight remote operation semantics', () => {
|
||||
const result = resolveSourceControlCommitAreaPrimaryActionDecision(
|
||||
inputs({
|
||||
isRemoteOperationActive: true,
|
||||
inFlightRemoteOpKind: 'pull',
|
||||
upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 }
|
||||
})
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
kind: 'pull',
|
||||
labelIntent: 'pull',
|
||||
titleIntent: 'action_in_progress',
|
||||
disabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('marks patch-equivalent diverged branches as force-push-with-lease decisions', () => {
|
||||
const result = resolveSourceControlCommitAreaPrimaryActionDecision(
|
||||
inputs({
|
||||
branchCommitsAhead: 4,
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 14,
|
||||
behind: 3,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
kind: 'push',
|
||||
labelIntent: 'force_push',
|
||||
titleIntent: 'force_push_with_lease',
|
||||
requiresForceWithLease: true,
|
||||
count: 4,
|
||||
upstreamName: 'origin/feature'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps review creation out of commit-area decisions', () => {
|
||||
const input = inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
expect(resolveSourceControlPrimaryActionDecision(input).kind).toBe('create_pr')
|
||||
expect(resolveSourceControlCommitAreaPrimaryActionDecision(input).kind).toBe('commit')
|
||||
})
|
||||
|
||||
it('keeps in-flight review intents out of commit-area decisions', () => {
|
||||
const input = inputs({ isPrIntentInFlight: true })
|
||||
expect(resolveSourceControlPrimaryActionDecision(input).kind).toBe('create_pr_intent')
|
||||
expect(resolveSourceControlCommitAreaPrimaryActionDecision(input).kind).toBe('commit')
|
||||
})
|
||||
|
||||
it('returns disabled create review while hosted-review creation eligibility is loading', () => {
|
||||
const input = inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
reviewLookupOutcome: 'not_found'
|
||||
},
|
||||
isHostedReviewCreationLoading: true
|
||||
})
|
||||
expect(resolveSourceControlPrimaryActionDecision(input)).toMatchObject({
|
||||
kind: 'create_pr',
|
||||
titleIntent: 'checking_review_creation',
|
||||
disabled: true
|
||||
})
|
||||
expect(resolveSourceControlCommitAreaPrimaryActionDecision(input).kind).toBe('commit')
|
||||
})
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HostedReviewCreationEligibility } from './hosted-review-contracts'
|
||||
import { isHostedReviewCreateEligibilityConfirmable } from './hosted-review-create-confirmation'
|
||||
|
||||
function eligibility(
|
||||
overrides: Partial<HostedReviewCreationEligibility> = {}
|
||||
): HostedReviewCreationEligibility {
|
||||
return {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
reviewLookupOutcome: 'not_found',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('isHostedReviewCreateEligibilityConfirmable', () => {
|
||||
const base = { reviewLookup: 'not_found' as const, hasHardRefreshError: false }
|
||||
|
||||
it('fails closed on every hard-block: null, existing_review, positive, hard error, unavailable', () => {
|
||||
expect(isHostedReviewCreateEligibilityConfirmable({ ...base, eligibility: null })).toBe(false)
|
||||
expect(
|
||||
isHostedReviewCreateEligibilityConfirmable({
|
||||
...base,
|
||||
eligibility: eligibility({ canCreate: false, blockedReason: 'existing_review' })
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isHostedReviewCreateEligibilityConfirmable({
|
||||
...base,
|
||||
eligibility: eligibility(),
|
||||
reviewLookup: 'positive_unresolved'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isHostedReviewCreateEligibilityConfirmable({
|
||||
...base,
|
||||
eligibility: eligibility(),
|
||||
hasHardRefreshError: true
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isHostedReviewCreateEligibilityConfirmable({
|
||||
...base,
|
||||
eligibility: eligibility({
|
||||
blockedReason: 'needs_push',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveCreateReviewSeedFromCommits } from './hosted-review-create-seed'
|
||||
|
||||
const github = { provider: 'github' as const, title: '', body: '' }
|
||||
|
||||
describe('resolveCreateReviewSeedFromCommits', () => {
|
||||
it('uses the only commit for both title and description, as GitHub does', () => {
|
||||
expect(
|
||||
resolveCreateReviewSeedFromCommits({
|
||||
branch: 'kelvin/fix-sync',
|
||||
eligibility: github,
|
||||
commits: [
|
||||
{ subject: 'fix(git): keep the PR panel in sync', body: 'Refetch after a force push.' }
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
title: 'fix(git): keep the PR panel in sync',
|
||||
body: 'Refetch after a force push.'
|
||||
})
|
||||
})
|
||||
|
||||
it('titles several commits from the branch and lists their subjects oldest first', () => {
|
||||
expect(
|
||||
resolveCreateReviewSeedFromCommits({
|
||||
branch: 'refs/heads/kelvin/mobile-redesign',
|
||||
eligibility: github,
|
||||
commits: [{ subject: 'feat: add ListRow' }, { subject: 'feat: rebuild Tasks' }]
|
||||
})
|
||||
).toEqual({ title: 'Mobile redesign', body: '- feat: add ListRow\n- feat: rebuild Tasks' })
|
||||
})
|
||||
|
||||
it('keeps a title and description the host already supplied', () => {
|
||||
expect(
|
||||
resolveCreateReviewSeedFromCommits({
|
||||
branch: 'kelvin/x',
|
||||
eligibility: { provider: 'github', title: 'Host title', body: 'Host body' },
|
||||
commits: [{ subject: 'one', body: 'commit body' }]
|
||||
})
|
||||
).toEqual({ title: 'Host title', body: 'Host body' })
|
||||
})
|
||||
|
||||
it('falls back to the branch seed when no commits are known', () => {
|
||||
expect(
|
||||
resolveCreateReviewSeedFromCommits({
|
||||
branch: 'kelvin/add-thing',
|
||||
eligibility: github,
|
||||
commits: []
|
||||
})
|
||||
).toEqual({ title: 'Add thing', body: '' })
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveHostedReviewCreationProvider,
|
||||
supportsHostedReviewCreation
|
||||
} from './hosted-review-creation-providers'
|
||||
|
||||
describe('hosted review creation providers', () => {
|
||||
// The create-review blocker uses this list; missing implemented providers would remain unsupported in the UI.
|
||||
it('accepts every provider Orca can create a review on', () => {
|
||||
for (const provider of ['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea'] as const) {
|
||||
expect(supportsHostedReviewCreation(provider)).toBe(true)
|
||||
expect(resolveHostedReviewCreationProvider(provider)).toBe(provider)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unsupported and missing providers, falling back to GitHub copy', () => {
|
||||
expect(supportsHostedReviewCreation('unsupported')).toBe(false)
|
||||
expect(supportsHostedReviewCreation(null)).toBe(false)
|
||||
expect(supportsHostedReviewCreation(undefined)).toBe(false)
|
||||
expect(resolveHostedReviewCreationProvider('unsupported')).toBe('github')
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hostedReviewInfoFromGitHubPRInfo } from './hosted-review-github'
|
||||
import type { PRInfo } from '../../github/pull-request-types'
|
||||
|
||||
const pr: PRInfo = {
|
||||
number: 12,
|
||||
title: 'Add queue badges',
|
||||
state: 'open',
|
||||
url: 'https://github.com/acme/orca/pull/12',
|
||||
checksStatus: 'pending',
|
||||
updatedAt: '2026-05-12T00:00:00.000Z',
|
||||
mergeable: 'MERGEABLE',
|
||||
headSha: 'abc123'
|
||||
}
|
||||
|
||||
describe('hostedReviewInfoFromGitHubPRInfo', () => {
|
||||
it('maps PRInfo into sidebar hosted review metadata', () => {
|
||||
const githubRepository = { owner: 'upstream', repo: 'orca' }
|
||||
const review = hostedReviewInfoFromGitHubPRInfo({ ...pr, prRepo: githubRepository })
|
||||
|
||||
expect(review).toMatchObject({
|
||||
provider: 'github',
|
||||
number: 12,
|
||||
title: 'Add queue badges',
|
||||
state: 'open',
|
||||
status: 'pending',
|
||||
mergeable: 'MERGEABLE',
|
||||
headSha: 'abc123',
|
||||
githubRepository
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,226 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasPositiveHostedReviewNumberLink,
|
||||
hasResolvableHostedReviewPushTargetLink,
|
||||
hasUsableHostedReviewPushTarget,
|
||||
resolveHostedReviewActionUpstreamStatus,
|
||||
resolveHostedReviewStateForActions
|
||||
} from './hosted-review-push-target'
|
||||
|
||||
const unrelatedUpstream = {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/helper-branch',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
|
||||
describe('resolveHostedReviewActionUpstreamStatus', () => {
|
||||
it('blocks action status from using a local upstream while linked review state is loading', () => {
|
||||
expect(
|
||||
resolveHostedReviewActionUpstreamStatus({
|
||||
hasHostedReviewLink: true,
|
||||
hasResolvableHostedReviewPushTargetLink: false,
|
||||
hostedReviewState: null,
|
||||
isHostedReviewStateLoading: true,
|
||||
canUseHostedReviewPushTarget: false,
|
||||
upstreamStatus: unrelatedUpstream
|
||||
})
|
||||
).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
})
|
||||
|
||||
it('blocks action status from using a local upstream for open reviews without a target', () => {
|
||||
expect(
|
||||
resolveHostedReviewActionUpstreamStatus({
|
||||
hasHostedReviewLink: true,
|
||||
hasResolvableHostedReviewPushTargetLink: false,
|
||||
hostedReviewState: 'open',
|
||||
isHostedReviewStateLoading: false,
|
||||
canUseHostedReviewPushTarget: false,
|
||||
upstreamStatus: unrelatedUpstream
|
||||
})
|
||||
).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
})
|
||||
|
||||
it('blocks explicit linked review metadata even when review state is unavailable', () => {
|
||||
expect(
|
||||
resolveHostedReviewActionUpstreamStatus({
|
||||
hasHostedReviewLink: true,
|
||||
hasResolvableHostedReviewPushTargetLink: true,
|
||||
hostedReviewState: null,
|
||||
isHostedReviewStateLoading: false,
|
||||
canUseHostedReviewPushTarget: false,
|
||||
upstreamStatus: unrelatedUpstream
|
||||
})
|
||||
).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
})
|
||||
|
||||
it('does not block unknown-state review links when no target lookup exists', () => {
|
||||
expect(
|
||||
resolveHostedReviewActionUpstreamStatus({
|
||||
hasHostedReviewLink: true,
|
||||
hasResolvableHostedReviewPushTargetLink: false,
|
||||
hostedReviewState: null,
|
||||
isHostedReviewStateLoading: false,
|
||||
canUseHostedReviewPushTarget: false,
|
||||
upstreamStatus: unrelatedUpstream
|
||||
})
|
||||
).toBe(unrelatedUpstream)
|
||||
})
|
||||
|
||||
it('does not alter status for closed reviews', () => {
|
||||
expect(
|
||||
resolveHostedReviewActionUpstreamStatus({
|
||||
hasHostedReviewLink: true,
|
||||
hasResolvableHostedReviewPushTargetLink: true,
|
||||
hostedReviewState: 'closed',
|
||||
isHostedReviewStateLoading: false,
|
||||
canUseHostedReviewPushTarget: false,
|
||||
upstreamStatus: unrelatedUpstream
|
||||
})
|
||||
).toBe(unrelatedUpstream)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasResolvableHostedReviewPushTargetLink', () => {
|
||||
it('accepts only hosted-review links with supported target lookup APIs', () => {
|
||||
expect(hasResolvableHostedReviewPushTargetLink({ linkedGitHubPR: 12 })).toBe(true)
|
||||
expect(hasResolvableHostedReviewPushTargetLink({ linkedGitLabMR: 34 })).toBe(true)
|
||||
// Why: a queue-discovered same-repo PR (no persisted linkedPR) is resolvable.
|
||||
expect(hasResolvableHostedReviewPushTargetLink({ fallbackGitHubPR: 8333 })).toBe(true)
|
||||
expect(
|
||||
hasResolvableHostedReviewPushTargetLink({ linkedGitHubPR: null, fallbackGitHubPR: 8333 })
|
||||
).toBe(true)
|
||||
expect(hasResolvableHostedReviewPushTargetLink({ fallbackGitHubPR: 0 })).toBe(false)
|
||||
expect(hasResolvableHostedReviewPushTargetLink({ linkedGitHubPR: null })).toBe(false)
|
||||
expect(hasResolvableHostedReviewPushTargetLink({ linkedGitHubPR: 0 })).toBe(false)
|
||||
expect(hasResolvableHostedReviewPushTargetLink({ linkedGitLabMR: -1 })).toBe(false)
|
||||
expect(hasResolvableHostedReviewPushTargetLink({ linkedGitLabMR: 1.5 })).toBe(false)
|
||||
expect(hasResolvableHostedReviewPushTargetLink({ linkedGitHubPR: Number.NaN })).toBe(false)
|
||||
expect(hasResolvableHostedReviewPushTargetLink({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasPositiveHostedReviewNumberLink', () => {
|
||||
it('blocks resolver-less providers without treating them as resolvable', () => {
|
||||
// Providers without resolvers still block unsafe pushes; keep resolvable links a strict subset of positive links.
|
||||
for (const provider of ['linkedBitbucketPR', 'linkedAzureDevOpsPR', 'linkedGiteaPR'] as const) {
|
||||
const args = { [provider]: 42 }
|
||||
expect(hasPositiveHostedReviewNumberLink(args)).toBe(true)
|
||||
expect(hasResolvableHostedReviewPushTargetLink(args)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveHostedReviewStateForActions', () => {
|
||||
it('treats explicit linked review metadata as open when live state is unavailable', () => {
|
||||
expect(
|
||||
resolveHostedReviewStateForActions({
|
||||
hostedReviewState: null,
|
||||
hasResolvableHostedReviewPushTargetLink: true
|
||||
})
|
||||
).toBe('open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasUsableHostedReviewPushTarget', () => {
|
||||
it('accepts either persisted target metadata or branch-configured push metadata', () => {
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
pushTarget: { remoteName: 'fork', branchName: 'feature' }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
pushTarget: { remoteName: 'fork', branchName: 'feature' },
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'fork/feature',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
upstreamStatus: {
|
||||
hasUpstream: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasConfiguredPushTarget: true
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
hasResolvableHostedReviewPushTargetLink: true,
|
||||
upstreamStatus: {
|
||||
hasUpstream: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasConfiguredPushTarget: true
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
pushTarget: { remoteName: 'fork', branchName: 'feature' },
|
||||
upstreamStatus: unrelatedUpstream
|
||||
})
|
||||
).toBe(false)
|
||||
expect(hasUsableHostedReviewPushTarget({ upstreamStatus: unrelatedUpstream })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps blocking a review whose upstream tracks an unrelated fork/helper head', () => {
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
hasResolvableHostedReviewPushTargetLink: true,
|
||||
branchName: 'feature',
|
||||
upstreamStatus: unrelatedUpstream
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps blocking a resolvable review with no real upstream', () => {
|
||||
expect(
|
||||
hasUsableHostedReviewPushTarget({
|
||||
hasResolvableHostedReviewPushTargetLink: true,
|
||||
branchName: 'feature',
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveHostedReviewActionUpstreamStatus with a same-repo upstream', () => {
|
||||
it('does not block push for a queue-discovered open PR whose upstream tracks the branch', () => {
|
||||
// Queue-discovered PRs lack persisted links but still resolve matching upstream targets; missing that wrongly disabled Push.
|
||||
const realUpstream = {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/fix-f1-codex-wsl-path-trust',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
const hasResolvable = hasResolvableHostedReviewPushTargetLink({
|
||||
linkedGitHubPR: null,
|
||||
fallbackGitHubPR: 8333,
|
||||
linkedGitLabMR: null
|
||||
})
|
||||
expect(hasResolvable).toBe(true)
|
||||
const canUseHostedReviewPushTarget = hasUsableHostedReviewPushTarget({
|
||||
hasResolvableHostedReviewPushTargetLink: hasResolvable,
|
||||
branchName: 'fix-f1-codex-wsl-path-trust',
|
||||
upstreamStatus: realUpstream
|
||||
})
|
||||
expect(canUseHostedReviewPushTarget).toBe(true)
|
||||
expect(
|
||||
resolveHostedReviewActionUpstreamStatus({
|
||||
hasHostedReviewLink: true,
|
||||
hasResolvableHostedReviewPushTargetLink: hasResolvable,
|
||||
hostedReviewState: 'open',
|
||||
isHostedReviewStateLoading: false,
|
||||
canUseHostedReviewPushTarget,
|
||||
upstreamStatus: realUpstream
|
||||
})
|
||||
).toBe(realUpstream)
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isRemoteHeadRef,
|
||||
normalizeHostedReviewBaseRef,
|
||||
normalizeHostedReviewHeadRef
|
||||
} from './hosted-review-refs'
|
||||
|
||||
describe('hosted review ref normalization', () => {
|
||||
it('normalizes local and remote head refs to branch names', () => {
|
||||
expect(normalizeHostedReviewHeadRef(' refs/heads/feature/create-pr ')).toBe('feature/create-pr')
|
||||
expect(normalizeHostedReviewHeadRef('refs/remotes/origin/feature/create-pr')).toBe(
|
||||
'feature/create-pr'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips common remote prefixes from base refs', () => {
|
||||
expect(normalizeHostedReviewBaseRef('origin/main')).toBe('main')
|
||||
expect(normalizeHostedReviewBaseRef('refs/remotes/upstream/release/1.0')).toBe('release/1.0')
|
||||
})
|
||||
})
|
||||
import { isRemoteHeadRef } from './hosted-review-refs'
|
||||
|
||||
describe('isRemoteHeadRef', () => {
|
||||
it('recognizes only a remote symbolic HEAD slot', () => {
|
||||
@@ -68,48 +50,3 @@ describe('isRemoteHeadRef', () => {
|
||||
expect(remotes).toEqual(['foo', 'foo/bar'])
|
||||
})
|
||||
})
|
||||
|
||||
/** Counts array copies (`[...remotes]`, one per sort) and elements copied, via the spread iterator. */
|
||||
function instrumentRemotes(names: readonly string[]): {
|
||||
remotes: readonly string[]
|
||||
counts: { copies: number; copiedElements: number }
|
||||
} {
|
||||
const remotes = [...names]
|
||||
const counts = { copies: 0, copiedElements: 0 }
|
||||
Object.defineProperty(remotes, Symbol.iterator, {
|
||||
configurable: true,
|
||||
value: function* countingIterator(this: readonly string[]) {
|
||||
counts.copies += 1
|
||||
for (let index = 0; index < this.length; index += 1) {
|
||||
counts.copiedElements += 1
|
||||
yield this[index] as string
|
||||
}
|
||||
}
|
||||
})
|
||||
return { remotes, counts }
|
||||
}
|
||||
|
||||
describe('isRemoteHeadRef fast path', () => {
|
||||
it('does not copy remotes while rejecting ordinary refs', () => {
|
||||
const ordinaryRefs = Array.from(
|
||||
{ length: 80 },
|
||||
(_unused, index) => `origin/feature/branch-${index}`
|
||||
)
|
||||
const remoteNames = ['origin', 'upstream', 'fork']
|
||||
|
||||
const after = instrumentRemotes(remoteNames)
|
||||
for (const ref of ordinaryRefs) {
|
||||
expect(isRemoteHeadRef(ref, after.remotes)).toBe(false)
|
||||
}
|
||||
expect(after.counts).toEqual({ copies: 0, copiedElements: 0 })
|
||||
})
|
||||
|
||||
it('copies each remote once for a symbolic HEAD candidate', () => {
|
||||
const remoteNames = ['origin', 'upstream', 'fork']
|
||||
|
||||
const after = instrumentRemotes(remoteNames)
|
||||
expect(isRemoteHeadRef('refs/remotes/origin/HEAD', after.remotes)).toBe(true)
|
||||
|
||||
expect(after.counts).toEqual({ copies: 1, copiedElements: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitStatusEntry } from '../../git/status/git-status-types'
|
||||
import { getConflictResolutions } from './source-control-entry-actions'
|
||||
|
||||
function conflict(overrides: Partial<GitStatusEntry>): GitStatusEntry {
|
||||
return {
|
||||
path: 'a.txt',
|
||||
area: 'unstaged',
|
||||
status: 'modified',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'unresolved',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('getConflictResolutions', () => {
|
||||
it('offers both sides of a delete/modify conflict while the surviving file is present', () => {
|
||||
expect(getConflictResolutions(conflict({ conflictKind: 'deleted_by_us' }))).toEqual([
|
||||
'keep_file',
|
||||
'keep_deletion'
|
||||
])
|
||||
})
|
||||
|
||||
it('offers only the deletion once the worktree file is gone', () => {
|
||||
expect(
|
||||
getConflictResolutions(conflict({ conflictKind: 'deleted_by_them', status: 'deleted' }))
|
||||
).toEqual(['keep_deletion'])
|
||||
expect(
|
||||
getConflictResolutions(conflict({ conflictKind: 'both_modified', status: 'deleted' }))
|
||||
).toEqual(['keep_deletion'])
|
||||
})
|
||||
|
||||
it('marks content conflicts resolved as edited', () => {
|
||||
expect(getConflictResolutions(conflict({}))).toEqual(['mark_resolved'])
|
||||
})
|
||||
|
||||
it('offers nothing for resolved rows or rows inside a submodule the parent cannot stage', () => {
|
||||
expect(getConflictResolutions(conflict({ conflictStatus: 'resolved_locally' }))).toEqual([])
|
||||
expect(getConflictResolutions(conflict({ submoduleRoot: 'sub' }))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_SOURCE_CONTROL_GROUP_ORDER,
|
||||
normalizeSourceControlGroupOrder
|
||||
} from './source-control-group-order'
|
||||
|
||||
describe('normalizeSourceControlGroupOrder', () => {
|
||||
it('keeps supported source control group orders', () => {
|
||||
expect(normalizeSourceControlGroupOrder('changes-first')).toBe('changes-first')
|
||||
expect(normalizeSourceControlGroupOrder('staged-first')).toBe('staged-first')
|
||||
})
|
||||
|
||||
it('maps the legacy untracked-first preference to combined Changes first', () => {
|
||||
expect(normalizeSourceControlGroupOrder('untracked-first')).toBe('changes-first')
|
||||
})
|
||||
|
||||
it('falls back to the default for malformed values', () => {
|
||||
expect(normalizeSourceControlGroupOrder('tracked-first')).toBe(
|
||||
DEFAULT_SOURCE_CONTROL_GROUP_ORDER
|
||||
)
|
||||
expect(normalizeSourceControlGroupOrder(undefined)).toBe(DEFAULT_SOURCE_CONTROL_GROUP_ORDER)
|
||||
})
|
||||
})
|
||||
@@ -1,167 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitStatusEntry } from '../../git/status/git-status-types'
|
||||
import {
|
||||
buildSourceControlDisplaySections,
|
||||
getSourceControlSectionViewAction,
|
||||
resolveSourceControlGroupOrder,
|
||||
splitPinnedSourceControlConflicts,
|
||||
type SourceControlEntryGroups
|
||||
} from './source-control-section-order'
|
||||
|
||||
function entry(partial: Partial<GitStatusEntry> & { path: string }): GitStatusEntry {
|
||||
return {
|
||||
area: 'unstaged',
|
||||
status: 'modified',
|
||||
...partial
|
||||
}
|
||||
}
|
||||
|
||||
function groups(partial: Partial<SourceControlEntryGroups>): SourceControlEntryGroups {
|
||||
return {
|
||||
staged: [],
|
||||
unstaged: [],
|
||||
untracked: [],
|
||||
...partial
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildSourceControlDisplaySections', () => {
|
||||
it('combines working files in path order before staged changes by default', () => {
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
groups({
|
||||
staged: [entry({ area: 'staged', path: 'staged.ts' })],
|
||||
unstaged: [entry({ path: 'working.ts' })],
|
||||
untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })]
|
||||
}),
|
||||
resolveSourceControlGroupOrder(undefined)
|
||||
)
|
||||
|
||||
expect(sections.map((section) => section.id)).toEqual(['unstaged', 'staged'])
|
||||
expect(sections[0]?.items.map((item) => item.path)).toEqual(['new.ts', 'working.ts'])
|
||||
})
|
||||
|
||||
it('pins conflict rows and removes them from the normal Changes section', () => {
|
||||
const unresolved = entry({
|
||||
area: 'unstaged',
|
||||
path: 'conflict.ts',
|
||||
conflictStatus: 'unresolved'
|
||||
})
|
||||
const resolved = entry({
|
||||
area: 'unstaged',
|
||||
path: 'resolved.ts',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
const normal = entry({ area: 'unstaged', path: 'normal.ts' })
|
||||
const input = groups({ unstaged: [unresolved, resolved, normal] })
|
||||
|
||||
const split = splitPinnedSourceControlConflicts(input)
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
input,
|
||||
resolveSourceControlGroupOrder('changes-first')
|
||||
)
|
||||
|
||||
expect(split.pinnedConflicts.map((item) => item.path)).toEqual(['conflict.ts', 'resolved.ts'])
|
||||
expect(split.normalGroups.unstaged.map((item) => item.path)).toEqual(['normal.ts'])
|
||||
expect(sections.map((section) => section.id)).toEqual(['conflicts', 'unstaged'])
|
||||
expect(sections[0]?.items.map((item) => item.path)).toEqual(['conflict.ts', 'resolved.ts'])
|
||||
expect(sections[1]?.items.map((item) => item.path)).toEqual(['normal.ts'])
|
||||
})
|
||||
|
||||
it('pins locally resolved staged conflicts and removes them from Staged Changes', () => {
|
||||
const resolvedStaged = entry({
|
||||
area: 'staged',
|
||||
path: 'resolved-staged.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
const staged = entry({ area: 'staged', path: 'staged.ts' })
|
||||
const input = groups({ staged: [resolvedStaged, staged] })
|
||||
|
||||
const split = splitPinnedSourceControlConflicts(input)
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
input,
|
||||
resolveSourceControlGroupOrder('staged-first')
|
||||
)
|
||||
|
||||
expect(split.pinnedConflicts).toEqual([resolvedStaged])
|
||||
expect(split.normalGroups.staged).toEqual([staged])
|
||||
expect(sections.map((section) => section.id)).toEqual(['conflicts', 'staged'])
|
||||
expect(sections[0]?.items[0]?.area).toBe('staged')
|
||||
expect(sections[1]?.items).toEqual([staged])
|
||||
})
|
||||
|
||||
it('routes the pinned Conflicts section to conflict review', () => {
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
groups({
|
||||
unstaged: [
|
||||
entry({
|
||||
area: 'unstaged',
|
||||
path: 'conflict.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'unresolved'
|
||||
}),
|
||||
entry({ area: 'unstaged', path: 'normal.ts' })
|
||||
]
|
||||
}),
|
||||
resolveSourceControlGroupOrder('changes-first')
|
||||
)
|
||||
|
||||
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
|
||||
kind: 'conflict-review',
|
||||
entries: [{ path: 'conflict.ts', conflictKind: 'both_modified' }]
|
||||
})
|
||||
expect(getSourceControlSectionViewAction(sections[1]!)).toEqual({
|
||||
kind: 'combined-diff',
|
||||
area: 'unstaged',
|
||||
entries: [entry({ area: 'unstaged', path: 'normal.ts' })]
|
||||
})
|
||||
})
|
||||
|
||||
it('routes locally resolved-only conflict sections to combined diff', () => {
|
||||
const resolved = entry({
|
||||
area: 'unstaged',
|
||||
path: 'resolved.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
groups({
|
||||
unstaged: [resolved]
|
||||
}),
|
||||
resolveSourceControlGroupOrder('changes-first')
|
||||
)
|
||||
|
||||
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
|
||||
kind: 'combined-diff',
|
||||
area: 'unstaged',
|
||||
entries: [resolved]
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a generic combined diff action for mixed-area resolved conflict sections', () => {
|
||||
const unstaged = entry({
|
||||
area: 'unstaged',
|
||||
path: 'resolved-unstaged.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
const staged = entry({
|
||||
area: 'staged',
|
||||
path: 'resolved-staged.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'resolved_locally'
|
||||
})
|
||||
const sections = buildSourceControlDisplaySections(
|
||||
groups({
|
||||
staged: [staged],
|
||||
unstaged: [unstaged]
|
||||
}),
|
||||
resolveSourceControlGroupOrder('staged-first')
|
||||
)
|
||||
|
||||
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
|
||||
kind: 'combined-diff',
|
||||
entries: [unstaged, staged]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,60 +19,7 @@ function entry(partial: Partial<GitStatusEntry> & { path: string }): GitStatusEn
|
||||
}
|
||||
}
|
||||
|
||||
function labels(nodes: ReturnType<typeof flattenSourceControlTree>): string[] {
|
||||
return nodes.map((node) => `${node.type}:${node.path}`)
|
||||
}
|
||||
|
||||
describe('buildSourceControlTree', () => {
|
||||
it('orders numbered sibling directories naturally, matching the file rows', () => {
|
||||
const tree = buildSourceControlTree('unstaged', [
|
||||
entry({ path: 'migrations/100 - b/a.sql' }),
|
||||
entry({ path: 'migrations/9 - a/a.sql' }),
|
||||
entry({ path: 'migrations/99 - c/a.sql' })
|
||||
])
|
||||
|
||||
expect(labels(flattenSourceControlTree(tree, new Set()))).toEqual([
|
||||
'directory:migrations',
|
||||
'directory:migrations/9 - a',
|
||||
'file:migrations/9 - a/a.sql',
|
||||
'directory:migrations/99 - c',
|
||||
'file:migrations/99 - c/a.sql',
|
||||
'directory:migrations/100 - b',
|
||||
'file:migrations/100 - b/a.sql'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps conflict rows ahead of normal rows within a directory', () => {
|
||||
const tree = buildGitStatusSourceControlTree('unstaged', [
|
||||
entry({ path: 'src/a.ts' }),
|
||||
entry({
|
||||
path: 'src/z.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'unresolved'
|
||||
})
|
||||
])
|
||||
|
||||
expect(labels(flattenSourceControlTree(tree, new Set()))).toEqual([
|
||||
'directory:src',
|
||||
'file:src/z.ts',
|
||||
'file:src/a.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('omits collapsed directory descendants from the visible rows', () => {
|
||||
const tree = buildSourceControlTree('staged', [
|
||||
entry({ path: 'src/app.ts', area: 'staged' }),
|
||||
entry({ path: 'docs/readme.md', area: 'staged' })
|
||||
])
|
||||
const collapsed = new Set(['dir::staged::src'])
|
||||
|
||||
expect(labels(flattenSourceControlTree(tree, collapsed))).toEqual([
|
||||
'directory:docs',
|
||||
'file:docs/readme.md',
|
||||
'directory:src'
|
||||
])
|
||||
})
|
||||
|
||||
it('collects all descendant file entries for directory actions', () => {
|
||||
const tree = buildSourceControlTree('unstaged', [
|
||||
entry({ path: 'src/app.ts' }),
|
||||
@@ -88,45 +35,6 @@ describe('buildSourceControlTree', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('compacts single-child directory chains for visible source-control rows', () => {
|
||||
const tree = buildSourceControlTree('branch', [
|
||||
{ path: 'src/renderer/src/mockups.css', status: 'modified' },
|
||||
{ path: 'src/renderer/src/components/App.tsx', status: 'added' }
|
||||
])
|
||||
|
||||
const compacted = compactSourceControlTree(tree)
|
||||
|
||||
expect(labels(flattenSourceControlTree(compacted, new Set()))).toEqual([
|
||||
'directory:src/renderer/src',
|
||||
'directory:src/renderer/src/components',
|
||||
'file:src/renderer/src/components/App.tsx',
|
||||
'file:src/renderer/src/mockups.css'
|
||||
])
|
||||
expect(compacted[0]).toMatchObject({
|
||||
name: 'src/renderer/src',
|
||||
path: 'src/renderer/src',
|
||||
depth: 0,
|
||||
key: 'dir::branch::src/renderer/src'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves real branching folders when compacting directory chains', () => {
|
||||
const tree = buildSourceControlTree('unstaged', [
|
||||
entry({ path: 'src/main/index.ts' }),
|
||||
entry({ path: 'src/renderer/index.ts' })
|
||||
])
|
||||
|
||||
const compacted = compactSourceControlTree(tree)
|
||||
|
||||
expect(labels(flattenSourceControlTree(compacted, new Set()))).toEqual([
|
||||
'directory:src',
|
||||
'directory:src/main',
|
||||
'file:src/main/index.ts',
|
||||
'directory:src/renderer',
|
||||
'file:src/renderer/index.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('can preserve file node areas from mixed conflict section entries', () => {
|
||||
const tree = compactSourceControlTree(
|
||||
buildGitStatusSourceControlTree('unstaged', [
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PUSH_FAILURE_PROMPT_FILE_LIMIT,
|
||||
PUSH_FAILURE_SUMMARY_SCAN_CODE_UNITS,
|
||||
buildFixPushFailurePrompt,
|
||||
hasExpandedPushFailureDetails,
|
||||
isPushHookFailure,
|
||||
sanitizePushFailureDetails,
|
||||
summarizePushFailure
|
||||
} from './source-control-push-failure'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('push failure detection and summary', () => {
|
||||
it('detects explicit pre-push hook failures', () => {
|
||||
const raw =
|
||||
@@ -42,22 +35,6 @@ describe('push failure detection and summary', () => {
|
||||
expect(isPushHookFailure(raw)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('strips ANSI and control output before details and comparison', () => {
|
||||
const raw = '\u001b[31mhusky - pre-push hook failed\u001b[0m\u0007\neslint failed'
|
||||
|
||||
expect(sanitizePushFailureDetails(raw)).toBe('husky - pre-push hook failed\neslint failed')
|
||||
expect(summarizePushFailure(raw)).toBe('Lint failed during push.')
|
||||
})
|
||||
|
||||
it('bounds summary analysis for pathological single-line logs', () => {
|
||||
const split = vi.spyOn(String.prototype, 'split')
|
||||
const raw = 'x'.repeat(PUSH_FAILURE_SUMMARY_SCAN_CODE_UNITS + 10_000)
|
||||
|
||||
expect(summarizePushFailure(raw)).toBe('x'.repeat(PUSH_FAILURE_SUMMARY_SCAN_CODE_UNITS))
|
||||
expect(hasExpandedPushFailureDetails(raw, 'Push failed.')).toBe(true)
|
||||
expect(split).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFixPushFailurePrompt', () => {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRComment } from '../../github/comment-types'
|
||||
import { createBotAuthorOverrideSet } from './pr-bot-author-overrides'
|
||||
import {
|
||||
filterPRCommentsByAudience,
|
||||
getPRCommentAudienceCounts,
|
||||
isBotPRComment
|
||||
} from './pr-comment-audience'
|
||||
|
||||
function comment(overrides: Partial<PRComment>): PRComment {
|
||||
return {
|
||||
id: overrides.id ?? 1,
|
||||
author: overrides.author ?? 'user',
|
||||
authorAvatarUrl: '',
|
||||
body: '',
|
||||
createdAt: '',
|
||||
url: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('pr comment audience filtering', () => {
|
||||
it('classifies provider metadata and automation logins', () => {
|
||||
expect(isBotPRComment(comment({ author: 'alice', isBot: true }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ author: 'github-actions[bot]' }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ author: 'renovate-bot' }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ author: 'preview-automation' }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ author: 'qodo-ai-reviewer', isBot: false }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ author: 'coderabbitai', isBot: false }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ author: 'sonarcloud' }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ author: 'codium-ai-reviewer' }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ author: 'octocat' }))).toBe(false)
|
||||
expect(isBotPRComment(comment({ author: 'robotics-dev' }))).toBe(false)
|
||||
expect(isBotPRComment(comment({ author: 'human-botany' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('counts and filters human and bot comments', () => {
|
||||
const comments = [
|
||||
comment({ id: 1, author: 'yasinkavakli' }),
|
||||
comment({ id: 2, author: 'chatgpt-codex-connector', isBot: true }),
|
||||
comment({ id: 3, author: 'github-actions[bot]' })
|
||||
]
|
||||
|
||||
expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 1, bot: 2 })
|
||||
expect(filterPRCommentsByAudience(comments, 'all')).toBe(comments)
|
||||
expect(filterPRCommentsByAudience(comments, 'human').map((item) => item.id)).toEqual([1])
|
||||
expect(filterPRCommentsByAudience(comments, 'bot').map((item) => item.id)).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it('classifies manually overridden authors as bots regardless of heuristics', () => {
|
||||
const overrides = createBotAuthorOverrideSet([' GretelFlux ', '', 'other-bot-account'])
|
||||
|
||||
expect(isBotPRComment(comment({ author: 'gretelflux' }))).toBe(false)
|
||||
expect(isBotPRComment(comment({ author: 'gretelflux' }), overrides)).toBe(true)
|
||||
expect(isBotPRComment(comment({ author: ' Gretelflux ' }), overrides)).toBe(true)
|
||||
expect(isBotPRComment(comment({ author: 'yasinkavakli' }), overrides)).toBe(false)
|
||||
})
|
||||
|
||||
it('applies overrides to counts and filters', () => {
|
||||
const overrides = createBotAuthorOverrideSet(['gretelflux'])
|
||||
const comments = [
|
||||
comment({ id: 1, author: 'yasinkavakli' }),
|
||||
comment({ id: 2, author: 'gretelflux' }),
|
||||
comment({ id: 3, author: 'github-actions[bot]' })
|
||||
]
|
||||
|
||||
expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 3, human: 1, bot: 2 })
|
||||
expect(filterPRCommentsByAudience(comments, 'human', overrides).map((item) => item.id)).toEqual(
|
||||
[1]
|
||||
)
|
||||
expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((item) => item.id)).toEqual([
|
||||
2, 3
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRComment } from '../../github/comment-types'
|
||||
import {
|
||||
getPRCommentGroupCount,
|
||||
getPRCommentGroupId,
|
||||
groupPRComments,
|
||||
isResolvedPRCommentGroup
|
||||
} from './pr-comment-groups'
|
||||
|
||||
function comment(overrides: Partial<PRComment>): PRComment {
|
||||
return {
|
||||
id: overrides.id ?? 1,
|
||||
author: 'user',
|
||||
authorAvatarUrl: '',
|
||||
body: '',
|
||||
createdAt: '',
|
||||
url: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('pr comment groups', () => {
|
||||
it('groups review thread replies while preserving first-comment order', () => {
|
||||
const comments = [
|
||||
comment({ id: 1 }),
|
||||
comment({ id: 2, threadId: 'thread-a', isResolved: true }),
|
||||
comment({ id: 3, threadId: 'thread-a' }),
|
||||
comment({ id: 4 })
|
||||
]
|
||||
const groups = groupPRComments(comments)
|
||||
|
||||
expect(groups.map(getPRCommentGroupId)).toEqual(['comment:1', 'thread:thread-a', 'comment:4'])
|
||||
expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] })
|
||||
expect(groups[1]).toMatchObject({ root: { id: 2 }, replies: [{ id: 3 }] })
|
||||
expect(getPRCommentGroupCount(groups[1])).toBe(2)
|
||||
expect(isResolvedPRCommentGroup(groups[1])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatPrCommentRelativeTime } from './pr-comment-time'
|
||||
|
||||
const NOW = Date.parse('2026-06-21T20:00:00.000Z')
|
||||
|
||||
describe('formatPrCommentRelativeTime', () => {
|
||||
it('formats recent and older comment timestamps compactly', () => {
|
||||
expect(formatPrCommentRelativeTime('2026-06-21T19:59:45.000Z', NOW)).toBe('just now')
|
||||
expect(formatPrCommentRelativeTime('2026-06-21T19:35:00.000Z', NOW)).toBe('25m ago')
|
||||
expect(formatPrCommentRelativeTime('2026-06-21T17:00:00.000Z', NOW)).toBe('3h ago')
|
||||
expect(formatPrCommentRelativeTime('2026-06-18T20:00:00.000Z', NOW)).toBe('3d ago')
|
||||
expect(formatPrCommentRelativeTime('2026-04-21T20:00:00.000Z', NOW)).toBe('2mo ago')
|
||||
expect(formatPrCommentRelativeTime('2024-06-21T20:00:00.000Z', NOW)).toBe('2y ago')
|
||||
})
|
||||
|
||||
it('returns an empty label for invalid timestamps', () => {
|
||||
expect(formatPrCommentRelativeTime('not a date', NOW)).toBe('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user