test: remove 295 low-value tests (#39494)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-07 05:10:40 -08:00
committed by GitHub
co-authored by OrcaWin
parent 3501cd2f1c
commit 5fc5aa435e
68 changed files with 11 additions and 5362 deletions
-2
View File
@@ -809,7 +809,6 @@
7 test-no-wall-clock:src/shared/__tests__/process-host/windows-tree-kill-late-taskkill.win32.test.ts
1 test-no-wall-clock:src/shared/agent-hook-listener/transport/agent-hook-spool-drain.test.ts
1 test-no-wall-clock:src/shared/agent-hook-listener/transport/agent-hook-spool-read.test.ts
1 test-no-wall-clock:src/shared/automation-schedules.test.ts
2 test-no-wall-clock:src/shared/crash-reporting.test.ts
2 test-no-wall-clock:src/shared/fish-query-reply-child-stdin.node-pty.test.ts
1 test-no-wall-clock:src/shared/mobile-push-contract.test.ts
@@ -840,7 +839,6 @@
1 test-timeout-needs-reason:src/cli/runtime/serve-pid1-init-reaper.test.ts
1 test-timeout-needs-reason:src/cli/skills-install.test.ts
2 test-timeout-needs-reason:src/main/acp/acp-structured-broken-stream.test.ts
1 test-timeout-needs-reason:src/main/acp/acp-structured-electron-free.test.ts
4 test-timeout-needs-reason:src/main/agent-hooks/server-transport-interference.test.ts
1 test-timeout-needs-reason:src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts
1 test-timeout-needs-reason:src/main/agent-hooks/wsl-hook-relay-manager.test.ts
@@ -494,22 +494,16 @@
"src/cli/wsl-distro-cli-refusal.test.ts": 74,
"src/main/acp/acp-agent-connection.test.ts": 407,
"src/main/acp/acp-at-rest-commands.test.ts": 1400,
"src/main/acp/acp-dialects/grok-model-catalog.test.ts": 118,
"src/main/acp/acp-dialects/grok-subagents.test.ts": 172,
"src/main/acp/acp-dialects/omp-dialect.test.ts": 105,
"src/main/acp/acp-fixture-privacy.test.ts": 129,
"src/main/acp/acp-json-rpc-peer.test.ts": 195,
"src/main/acp/acp-model-catalog-probe.test.ts": 774,
"src/main/acp/acp-permission-requests.test.ts": 254,
"src/main/acp/acp-prompt-content.test.ts": 302,
"src/main/acp/acp-scripted-child-runtime.test.ts": 2819,
"src/main/acp/acp-session-agent-turns.test.ts": 260,
"src/main/acp/acp-session-events.test.ts": 1189,
"src/main/acp/acp-session-lifecycle.test.ts": 303,
"src/main/acp/acp-session-notifications.test.ts": 249,
"src/main/acp/acp-session-runtime.test.ts": 310,
"src/main/acp/acp-structured-antigravity.test.ts": 3627,
"src/main/acp/acp-structured-auth.test.ts": 1864,
"src/main/acp/acp-structured-background-task-stop.test.ts": 4025,
"src/main/acp/acp-structured-broken-stream.test.ts": 1666,
"src/main/acp/acp-structured-child-session-end.test.ts": 3282,
@@ -518,7 +512,6 @@
"src/main/acp/acp-structured-child-work.test.ts": 4749,
"src/main/acp/acp-structured-command-publication.test.ts": 5167,
"src/main/acp/acp-structured-compaction.test.ts": 1981,
"src/main/acp/acp-structured-electron-free.test.ts": 299,
"src/main/acp/acp-structured-host-configured-default.test.ts": 2487,
"src/main/acp/acp-structured-host-lifecycle.test.ts": 3043,
"src/main/acp/acp-structured-host-live-listing.test.ts": 2602,
@@ -537,7 +530,6 @@
"src/main/acp/acp-structured-omp-recordings.test.ts": 2037,
"src/main/acp/acp-structured-opencode-recordings.test.ts": 2091,
"src/main/acp/acp-structured-private-temp.test.ts": 1003,
"src/main/acp/acp-structured-prompt-failure.test.ts": 2039,
"src/main/acp/acp-structured-request-admission.test.ts": 1403,
"src/main/acp/acp-structured-session-adapter-lifecycle.test.ts": 6322,
"src/main/acp/acp-structured-session-adapter-recordings.test.ts": 1686,
@@ -558,13 +550,10 @@
"src/main/acp/acp-timeline-grok-subagent-retention.test.ts": 3004,
"src/main/acp/acp-timeline-grok-subagents.test.ts": 1625,
"src/main/acp/acp-timeline-joins.test.ts": 790,
"src/main/acp/acp-timeline-open-enums.test.ts": 902,
"src/main/acp/acp-timeline-opencode-dialect.test.ts": 1409,
"src/main/acp/acp-timeline-recordings.test.ts": 1704,
"src/main/acp/acp-timeline-recovery.test.ts": 1493,
"src/main/acp/acp-timeline-routing.test.ts": 1503,
"src/main/acp/acp-timeline-turn-failures.test.ts": 1853,
"src/main/acp/acp-visuals-skill.test.ts": 43,
"src/main/acp/acp-write-queue-retention.test.ts": 423,
"src/main/acp/history/acp-history-boundary.test.ts": 1966,
"src/main/acp/history/acp-history-catch-up.test.ts": 2140,
@@ -574,15 +563,12 @@
"src/main/persistence/preferences/active-view-preference.test.ts": 61,
"src/main/providers/accounts/agent-auth-restart-preservation.test.ts": 55,
"src/main/agent-launch/preflight/agent-cli-version-probe.test.ts": 72,
"src/main/agent-hooks/agent-home-login-shell.test.ts": 81,
"src/main/agent-hooks/agent-tool-plugin-hooks.test.ts": 202,
"src/main/agent-hooks/agent-transition-detector.test.ts": 666,
"src/main/agent-hooks/branch-rename-failure-output.test.ts": 43,
"src/main/agent-hooks/cursor-replay-guard.test.ts": 403,
"src/main/agent-hooks/ended-process-reconciliation.test.ts": 775,
"src/main/agent-hooks/first-work-branch-rename-wsl-move.test.ts": 831,
"src/main/agent-hooks/first-work-branch-rename.test.ts": 857,
"src/main/agent-hooks/first-work-folder-rename.test.ts": 282,
"src/main/agent-hooks/grok-replay-guard.test.ts": 533,
"src/main/agent-hooks/hook-process-capture.test.ts": 118,
"src/main/agent-hooks/hook-script-outside-orca.test.ts": 171,
@@ -5548,30 +5534,19 @@
"src/preload/updater-package-recovery.test.ts": 1102,
"src/preload/usage-provider-api.test.ts": 46,
"src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts": 1786,
"src/renderer/src/app-shell/app-root-surface-settings.test.tsx": 480,
"src/renderer/src/app-shell/app-window-chrome.test.ts": 47,
"src/renderer/src/app-shell/headless-phone-window-parity.test.ts": 1904,
"src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts": 46,
"src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts": 1812,
"src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts": 65,
"src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts": 210,
"src/renderer/src/app-shell/startup-actions-selector.test.ts": 75,
"src/renderer/src/app-shell/startup-window-parity.test.ts": 2306,
"src/renderer/src/app-shell/use-profile-state-save-delay-notice.sonner.test.tsx": 355,
"src/renderer/src/app-shell/use-profile-state-save-delay-notice.test.tsx": 293,
"src/renderer/src/app-shell/use-visible-hosted-review-refresh.test.tsx": 572,
"src/renderer/src/app-shell/visible-review-reporting.test.ts": 1872,
"src/renderer/src/app-shell/window-visibility-actions-selector.test.ts": 269,
"src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx": 1170,
"src/renderer/src/app-shell/worktree-creation-chat-handoff.test.tsx": 2824,
"src/renderer/src/assets/theme-token-contrast.test.ts": 66,
"src/renderer/src/attention/agent-attention-policy.test.ts": 49,
"src/renderer/src/attention/agent-subject-read-actions.test.ts": 30,
"src/renderer/src/attention/hidden-workspace-notification-mute.test.ts": 1698,
"src/renderer/src/components/AgentProgressRing.test.tsx": 276,
"src/renderer/src/components/AgentStateDot.test.ts": 307,
"src/renderer/src/components/AgentWorkingSpinner.test.tsx": 234,
"src/renderer/src/components/ButtonKeyHint.test.tsx": 307,
"src/renderer/src/components/GitLabItemDialog.test.tsx": 2926,
"src/renderer/src/components/JiraIssueWorkspace.assignee-source.test.tsx": 2237,
"src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-reopen.test.tsx": 2153,
@@ -8650,7 +8625,6 @@
"src/shared/agent-session/options/structured-agent-session-permission-option.test.ts": 211,
"src/shared/agent-session/options/structured-agent-session-selection-operations.test.ts": 202,
"src/shared/agent-session/requests/structured-agent-session-dispatch-rejection.test.ts": 169,
"src/shared/agent-session/requests/structured-agent-session-latest-request.test.ts": 218,
"src/shared/agent-session/requests/structured-agent-session-send-evidence.test.ts": 148,
"src/shared/agent-session/structured-agent-session-coalescer.test.ts": 387,
"src/shared/agent-session/structured-agent-session-context-usage.test.ts": 108,
@@ -8700,23 +8674,16 @@
"src/shared/automation-cron-dialect.test.ts": 65,
"src/shared/automation-cron-input-validation.test.ts": 69,
"src/shared/automation-execution-target.test.ts": 51,
"src/shared/automation-host-filter.test.ts": 50,
"src/shared/automation-legacy-list-partition.test.ts": 63,
"src/shared/automation-list-response.test.ts": 67,
"src/shared/automation-list-scope.test.ts": 72,
"src/shared/automation-owner-key.test.ts": 49,
"src/shared/automation-owner-precondition.test.ts": 67,
"src/shared/automation-run-identity.test.ts": 38,
"src/shared/automation-run-retention.test.ts": 86,
"src/shared/automation-schedule-drift.test.ts": 82,
"src/shared/automation-schedule-dst.test.ts": 53,
"src/shared/automation-schedules.test.ts": 174,
"src/shared/automation-session-reuse.test.ts": 51,
"src/shared/automation-usage-summary.test.ts": 44,
"src/shared/base-ref-search-result.test.ts": 28,
"src/shared/binary-file-extensions.test.ts": 43,
"src/shared/bounded-map.test.ts": 69,
"src/shared/browser-annotation-viewport-lifecycle.test.ts": 205,
"src/shared/browser-annotation-viewport-live-target.test.ts": 259,
"src/shared/browser/extensions/browser-extension-permission-warnings.test.ts": 57,
"src/shared/browser/extensions/browser-extension-url.test.ts": 278,
@@ -57,8 +57,6 @@ const NODE_RUNTIME_INCLUDE = [
'src/main/acp/acp-text-message-ownership.test.ts',
'src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-clear-queued-command.test.ts',
'src/main/acp/acp-structured-sign-in.test.ts',
'src/main/acp/acp-structured-auth.test.ts',
'src/main/acp/acp-structured-prompt-failure.test.ts',
'src/main/acp/acp-timeline-turn-failures.test.ts',
'src/main/acp/acp-structured-session-adapter.test.ts',
'src/main/acp/acp-structured-session-adapter-resume.test.ts',
@@ -1,37 +0,0 @@
import { describe, expect, it } from 'vitest'
import model from '../fixtures/grok-v1-model.json'
import { grokModelEfforts } from './grok-model-catalog'
describe('Grok declared effort defaults', () => {
it('uses the recorded model metadata before menu recommendations, without guessing', () => {
// Grok 1.0.60 initialize capture, 2026-10-22; the model row is unchanged.
expect(grokModelEfforts(model).declaredDefaultEffort).toBe('high')
for (const [reasoningEffort, expected] of [
['xhigh', 'xhigh'],
[undefined, 'high'],
['unlisted', null],
[42, null]
]) {
expect(
grokModelEfforts({ _meta: { ...model._meta, reasoningEffort } }).declaredDefaultEffort
).toBe(expected)
expect(grokModelEfforts({ _meta: { ...model._meta, reasoningEffort } }).defaultEffort).toBe(
'high'
)
}
const unmarked = model._meta.reasoningEfforts.map((entry) => ({ ...entry, default: false }))
expect(
grokModelEfforts({ _meta: { ...model._meta, reasoningEfforts: unmarked } })
.declaredDefaultEffort
).toBe('high')
expect(
grokModelEfforts({
_meta: { ...model._meta, reasoningEfforts: unmarked, reasoningEffort: undefined }
}).declaredDefaultEffort
).toBeNull()
expect(
grokModelEfforts({ _meta: { supportsReasoningEffort: true, reasoningEffort: 'low' } })
.declaredDefaultEffort
).toBe('low')
})
})
@@ -1,152 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ToolCallUpdate } from '../generated/acp-protocol.generated'
import { OMP_ACP_DIALECT } from './omp-dialect'
const text = (value: string) => ({
type: 'content' as const,
content: { type: 'text' as const, text: value }
})
const normalize = (update: ToolCallUpdate) => OMP_ACP_DIALECT.normalizeToolUpdate!(update)
describe('OMP tool updates', () => {
it('leaves a tool without a command echo as it came', () => {
const update: ToolCallUpdate = {
toolCallId: 'call-1',
status: 'completed',
rawOutput: { content: [{ type: 'text', text: 'file body' }], details: {} },
content: [text('file body')]
}
const expected = structuredClone(update)
expect(normalize(update)).toEqual(expected)
})
it('keeps a result that itself starts with "$ "', () => {
const update: ToolCallUpdate = {
toolCallId: 'call-1',
status: 'completed',
rawOutput: { content: [{ type: 'text', text: '$ 5.00' }], details: {} },
content: [text('$ 5.00')]
}
const expected = structuredClone(update)
expect(normalize(update)).toEqual(expected)
})
it.each([0, 3])('keeps an exit %s notice when OMP sent no matching detail', (code) => {
const output = `done\n\nCommand exited with code ${code}`
const update = normalize({
toolCallId: 'call-1',
status: 'completed',
rawOutput: {
content: [{ type: 'text', text: output }],
details: { wallTimeMs: 12 }
},
content: [text('$ ./run'), text(output)]
})
expect(update).toMatchObject({
content: [],
rawOutput: { stdout: output, exitCode: 0 }
})
})
const command = (
status: ToolCallUpdate['status'],
details: Record<string, unknown>,
rawInput?: unknown
): ToolCallUpdate => ({
toolCallId: 'call-1',
status,
...(rawInput === undefined ? {} : { rawInput }),
rawOutput: { content: [{ type: 'text', text: 'ok' }], details },
content: [text('$ ./run'), text('ok')]
})
it('shows exit 0 for a foreground command that completed, since OMP omits a zero exit', () => {
expect(normalize(command('completed', { wallTimeMs: 12 })).rawOutput).toMatchObject({
stdout: 'ok',
exitCode: 0
})
expect(
normalize(command('completed', { wallTimeMs: 0, signal: null })).rawOutput
).toMatchObject({
exitCode: 0
})
})
it('infers no exit code for a command that is running, timed out, signalled or in the background', () => {
for (const update of [
command('in_progress', { wallTimeMs: 12 }),
command('completed', { wallTimeMs: 12, timedOut: true }),
command('completed', { wallTimeMs: 12, signal: 'SIGTERM' }),
command('completed', { wallTimeMs: 12, async: { jobId: 'job-1' } }),
command('completed', { wallTimeMs: 12 }, { command: './run', async: true })
]) {
expect(normalize(update).rawOutput).not.toHaveProperty('exitCode')
}
})
it('infers no exit code without foreground timing metadata', () => {
for (const details of [undefined, {}, { signal: null }]) {
const update = normalize({
toolCallId: 'call-1',
status: 'completed',
rawOutput: { content: [{ type: 'text', text: 'ok' }], ...(details ? { details } : {}) },
content: [text('$ ./run'), text('ok')]
})
expect(update.rawOutput).toMatchObject({ stdout: 'ok' })
expect(update.rawOutput).not.toHaveProperty('exitCode')
}
})
it('does not confuse OMP v18.4 service-start completion with a process exit', () => {
for (const timing of [{}, { wallTimeMs: 12 }]) {
const update = normalize({
toolCallId: 'service-call',
status: 'completed',
rawOutput: {
content: [{ type: 'text', text: 'web: running pid=123 ready' }],
details: {
...timing,
service: { name: 'web', state: 'running', ready: true, timedOut: false, pid: 123 }
}
},
content: [text('$ python3 -m http.server 8765'), text('web: running pid=123 ready')]
})
expect(update.rawOutput).toMatchObject({ stdout: 'web: running pid=123 ready' })
expect(update.rawOutput).not.toHaveProperty('exitCode')
}
})
it('leaves an existing generic command result intact', () => {
for (const result of [
{ exitCode: 3 },
{ exit_code: 3 },
{ stdout: 'original' },
{ stderr: 'error' },
{ output_for_prompt: 'original' },
{ signal: 'SIGTERM' },
{ timed_out: true }
]) {
const update = command('completed', { wallTimeMs: 12 })
update.rawOutput = {
...result,
content: [{ type: 'text', text: 'ok' }],
details: { wallTimeMs: 12 }
}
const expected = structuredClone(update)
expect(normalize(update)).toEqual(expected)
}
})
it("keeps a failed command's reported exit code", () => {
const update = normalize({
toolCallId: 'call-1',
status: 'failed',
rawOutput: {
content: [{ type: 'text', text: 'hi\n\nCommand exited with code 3' }],
details: { exitCode: 3 }
},
content: [text('$ ./run'), text('hi\n\nCommand exited with code 3')]
})
expect(update.rawOutput).toMatchObject({ stdout: 'hi', exitCode: 3 })
})
})
-101
View File
@@ -1,101 +0,0 @@
import { readFile, readdir } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
const privateText =
/\/Users\/|\/home\/|[a-z]:[\\/]+Users[\\/]|[\w.+-]+@[\w.-]+\.[a-z]{2,}|\bhostname\b|\b[\w-]+\.(?:local|lan)\b|signature|subscription_tier|skills?/i
function decodedStrings(value: unknown, depth = 0): string[] {
if (depth > 8) {
return []
}
if (Array.isArray(value)) {
if (
value.length > 0 &&
value.every(
(entry) =>
typeof entry === 'number' && Number.isInteger(entry) && entry >= 0 && entry <= 255
)
) {
return decodedStrings(Buffer.from(value).toString('utf8'), depth + 1)
}
return value.flatMap((entry) => decodedStrings(entry, depth + 1))
}
if (typeof value === 'object' && value !== null) {
return Object.entries(value).flatMap(([key, entry]) => [
key,
...decodedStrings(entry, depth + 1)
])
}
if (typeof value !== 'string') {
return []
}
const texts = [value]
try {
const decoded = decodeURIComponent(value)
if (decoded !== value) {
texts.push(...decodedStrings(decoded, depth + 1))
}
} catch {
/* Provider text need not be URI encoded. */
}
try {
const decoded: unknown = JSON.parse(value)
if (decoded !== value) {
texts.push(...decodedStrings(decoded, depth + 1))
}
} catch {
/* Ordinary text is not JSON. */
}
for (const token of value.match(/[A-Za-z0-9+/_-]{8,}={0,2}/g) ?? []) {
const decoded = Buffer.from(token, 'base64url').toString('utf8')
if (
decoded !== token &&
!decoded.includes('\uFFFD') &&
[...decoded].every(
(char) =>
['\n', '\r', '\t'].includes(char) ||
(char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127)
)
) {
texts.push(...decodedStrings(decoded, depth + 1))
}
}
return texts
}
describe('ACP recording privacy', () => {
it.each([
'/Users/u',
'/home/u',
'/home/é',
'/Users/private/project',
'/home/private/project',
'C:\\Users\\private\\project'
])('detects home paths after nested JSON, URL, base64 and byte encoding: %s', (path) => {
for (const value of [
path,
encodeURIComponent(path),
Buffer.from(path).toString('base64'),
[...Buffer.from(path)],
JSON.stringify({ nested: JSON.stringify(path) })
]) {
expect(decodedStrings(value).some((text) => privateText.test(text))).toBe(true)
}
})
it('contains no personal metadata or home-directory paths in any decoded fixture', async () => {
const directory = new URL('./fixtures/', import.meta.url)
const names = (await readdir(directory)).filter((name) => name.endsWith('.jsonl'))
expect(names.length).toBeGreaterThan(0)
for (const name of names) {
const content = await readFile(new URL(name, directory), 'utf8')
for (const [index, line] of content.trim().split('\n').entries()) {
const value: unknown = JSON.parse(line)
expect(
decodedStrings(value).some((text) => privateText.test(text)),
`${name}:${index + 1}`
).toBe(false)
}
}
})
})
+1 -70
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime'
import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support'
import type { AcpPermissionHandler } from './acp-permission-requests'
import type { RequestPermissionRequest } from './generated/acp-protocol.generated'
const opened: { close: () => void }[] = []
@@ -29,75 +29,6 @@ afterEach(() =>
)
describe('ACP permission requests', () => {
it.each([
['a newer tool kind', { ...toolCall, kind: 'web_search' }, allow],
['a newer tool status', { ...toolCall, status: 'cancelled' }, allow],
['a newer option kind', toolCall, { ...allow, kind: 'allow_for_session' }]
])('delivers a permission with %s to the caller unchanged', async (_label, call, option) => {
const asked = vi.fn((_request: RequestPermissionRequest) => ({
outcome: { outcome: 'selected' as const, optionId: option.optionId }
}))
const { agent, runtime, diagnostics } = fixture({ onPermission: asked })
await runtime.start(startOptions)
void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {})
const params = { sessionId: 'session-1', toolCall: call, options: [option] }
expect(await agent.request('p', 'session/request_permission', params)).toMatchObject({
result: { outcome: { outcome: 'selected', optionId: option.optionId } }
})
expect(asked.mock.calls[0][0]).toEqual(params)
expect(diagnostics).toEqual([])
})
it('delivers a permission whose other fields are unreadable, dropping only those fields', async () => {
const asked = vi.fn((_request: RequestPermissionRequest) => ({
outcome: { outcome: 'cancelled' as const }
}))
const { agent, runtime, diagnostics } = fixture({ onPermission: asked })
await runtime.start(startOptions)
void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {})
const content = [{ type: 'content', content: { type: 'reasoning_summary', text: 'x' } }]
await agent.request('p', 'session/request_permission', {
sessionId: 'session-1',
toolCall: { ...toolCall, content, vendor: 'kept' },
options: [{ optionId: 'allow', kind: 'allow_once' }, { name: 'No id' }]
})
expect(asked.mock.calls[0][0]).toEqual({
sessionId: 'session-1',
toolCall: { ...toolCall, vendor: 'kept' },
options: [{ optionId: 'allow', name: 'allow', kind: 'allow_once' }]
})
expect(diagnostics).toEqual([
'Delivered ACP permission request without unreadable fields: options.0.name, options.1, toolCall.content'
])
})
it.each<[string, AcpPermissionHandler, string]>([
[
'the handler throws',
() => {
throw new Error('renderer gone')
},
'handler failed: Error: renderer gone'
],
[
'the handler answers nonsense',
() => JSON.parse('{"outcome":"yes"}'),
'invalid handler response'
]
])('answers cancelled with a diagnostic when %s', async (_label, onPermission, problem) => {
const { agent, runtime, diagnostics } = fixture({ onPermission })
await runtime.start(startOptions)
void runtime.prompt([{ type: 'text', text: 'hi' }]).catch(() => {})
expect(
await agent.request('p', 'session/request_permission', {
sessionId: 'session-1',
toolCall,
options: [allow]
})
).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } })
expect(diagnostics).toEqual([`Answered ACP permission request cancelled: ${problem}`])
})
it('lets the owner decline a permission after cancel, before the handler starts', async () => {
let stopping = false
const asked = vi.fn((_request: RequestPermissionRequest) => ({
+3 -92
View File
@@ -1,20 +1,13 @@
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it } from 'vitest'
import { AcpAgentError, AcpInvalidResponseError } from './acp-errors'
import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime'
import { AcpSessionRuntime } from './acp-session-runtime'
import { AcpScriptedAgent, type FakeFrame, tick } from './acp-scripted-agent.test-support'
const opened: { close: () => void }[] = []
const startOptions = { cwd: '/runtime/project', mcpServers: [] }
const prompt = [{ type: 'text', text: 'hello' }] as const
function fixture(options: AcpSessionRuntimeOptions = {}) {
const agent = new AcpScriptedAgent()
const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, options)
opened.push(agent, runtime)
agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 }))
agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1' }))
return { agent, runtime }
}
afterEach(() =>
opened
.splice(0)
@@ -23,88 +16,6 @@ afterEach(() =>
)
describe('ACP turns the agent begins and the runtime contract around them', () => {
it('delivers extension notifications in arrival order with session updates', async () => {
const order: string[] = []
const { agent, runtime } = fixture({
onExtensionNotification: (method, params) => order.push(`${method} ${JSON.stringify(params)}`)
})
runtime.subscribe((event) => order.push(`update ${event.kind}`))
await runtime.start(startOptions)
const completed = { sessionId: 'session-1', update: { type: 'turn_completed', prompt_id: 'p' } }
agent.stdout.write(
[
{ jsonrpc: '2.0', method: '_x.ai/session_notification', params: { started: true } },
{
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: 'session-1',
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'x' } }
}
},
{ jsonrpc: '2.0', method: '_x.ai/session_notification', params: completed }
]
.map((frame) => `${JSON.stringify(frame)}\n`)
.join('')
)
await tick()
expect(order).toEqual([
'_x.ai/session_notification {"started":true}',
'update known',
`_x.ai/session_notification ${JSON.stringify(completed)}`
])
})
it('sends session/cancel with no prompt of Orca running, and _meta on every session call', async () => {
const { agent, runtime } = fixture()
agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'end_turn' }))
agent.on('session/set_mode', (frame) => agent.reply(frame, {}))
agent.on('session/set_model', (frame) => agent.reply(frame, {}))
agent.on('session/set_config_option', (frame) => agent.reply(frame, { configOptions: [] }))
await runtime.start(startOptions)
const meta = { traceId: 't' }
await runtime.prompt([...prompt], meta)
await runtime.setMode('plan', meta)
await runtime.setModel('model-1', meta)
await runtime.setConfigOption('effort', 'high', meta)
await runtime.cancel({ meta })
await tick()
const sent = agent.frames.filter((frame) => frame.method?.startsWith('session/'))
expect(sent.map((frame) => frame.method)).toEqual([
'session/new',
'session/prompt',
'session/set_mode',
'session/set_model',
'session/set_config_option',
'session/cancel'
])
for (const frame of sent.slice(1)) {
expect(frame.params).toMatchObject({ sessionId: 'session-1', _meta: meta })
}
})
it("separates the agent's own errors from answers Orca could not read", async () => {
const { agent, runtime } = fixture()
await runtime.start(startOptions)
agent.on('session/set_mode', (frame) => agent.fail(frame, -32603, 'Agent broke', { why: 1 }))
const refused = await runtime.setMode('plan').catch((error: unknown) => error)
expect(refused).toBeInstanceOf(AcpAgentError)
expect(refused).toMatchObject({ code: -32603, data: { why: 1 } })
agent.on('session/set_mode', (frame) => agent.reply(frame, 'not-an-object'))
const unreadable = await runtime.setMode('plan').catch((error: unknown) => error)
expect(unreadable).toBeInstanceOf(AcpInvalidResponseError)
expect(unreadable).not.toBeInstanceOf(AcpAgentError)
expect(unreadable).toMatchObject({ code: -32603, data: 'not-an-object' })
expect(unreadable instanceof AcpInvalidResponseError && unreadable.issues).toBeTruthy()
})
it('completes a turn whose stop reason is newer than this schema', async () => {
const { agent, runtime } = fixture()
agent.on('session/prompt', (frame) => agent.reply(frame, { stopReason: 'context_exhausted' }))
await runtime.start(startOptions)
expect(await runtime.prompt([...prompt])).toEqual({ stopReason: 'context_exhausted' })
})
it('retries a cancel whose write failed instead of returning the stale failure', async () => {
const agent = new AcpScriptedAgent()
const stdin = new PassThrough({ highWaterMark: 1 })
-74
View File
@@ -1,74 +0,0 @@
import { readdir } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
import { readAcpFixture } from './acp-timeline-fixture.test-support'
import { readAcpSessionEvent } from './acp-session-events'
import { SessionNotificationSchema } from './generated/acp-protocol.generated'
describe('compiled ACP session notifications', () => {
it('matches the runtime parser across the captured session updates', async () => {
const files = await readdir(new URL('./fixtures/', import.meta.url))
let compared = 0
for (const file of files) {
if (
!file.endsWith('.jsonl') ||
file.endsWith('.journal.jsonl') ||
file.endsWith('.render.jsonl')
) {
continue
}
const frames = await readAcpFixture(file.slice(0, -'.jsonl'.length))
for (const frame of frames) {
if (frame.message.method !== 'session/update') {
continue
}
const params = frame.message.params
const snapshot = structuredClone(params)
const original = SessionNotificationSchema.safeParse(params)
const event = readAcpSessionEvent(params)
if (original.success) {
expect(event).toEqual({ kind: 'known', notification: original.data })
} else {
expect(event).toMatchObject({ kind: 'unrecognized', raw: params })
}
expect(params).toEqual(snapshot)
compared++
}
}
expect(compared).toBeGreaterThan(0)
})
it('preserves additive fields and open enum values without mutating the input', () => {
const params = {
sessionId: 'session-1',
update: {
sessionUpdate: 'tool_call',
toolCallId: 'tool-1',
title: 'Search',
kind: 'future-tool-kind',
content: [],
vendor: { retained: true }
},
vendor: true
}
const original = structuredClone(params)
expect(readAcpSessionEvent(params)).toEqual({ kind: 'known', notification: original })
expect(params).toEqual(original)
})
it.each([
{ sessionUpdate: 'vendor_usage', tokens: 1 },
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 42 } }
])('keeps unknown and malformed known updates for the dialect (%j)', (update) => {
const params = { sessionId: 'session-1', update, vendor: true }
expect(readAcpSessionEvent(params)).toEqual({
kind: 'unrecognized',
sessionId: 'session-1',
raw: params
})
})
it.each([null, {}, { sessionId: 'session-1', update: {} }])(
'rejects malformed notification envelopes (%j)',
(params) => expect(readAcpSessionEvent(params)).toBeNull()
)
})
+2 -158
View File
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { AcpJsonRpcPeer } from './acp-json-rpc-peer'
import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime'
import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support'
import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors'
import { AcpConnectionClosedError } from './acp-errors'
import type { RequestPermissionResponse } from './generated/acp-protocol.generated'
const opened: { close: () => void }[] = []
@@ -96,59 +96,6 @@ describe('ACP caller-owned waits', () => {
await expect(pending).resolves.toEqual({ stopReason: 'end_turn' })
})
it.each([
{ id: 'key', name: 'API key', type: 'env_var', vars: [{ name: 'X_API_KEY' }] },
{ id: 'browser', name: 'Browser login', type: 'agent' },
{ id: 'default', name: 'Default login' },
{ id: 'terminal', name: 'Terminal login', type: 'terminal' }
])('surfaces $id authentication without choosing it for the caller', async (method) => {
const { agent, runtime } = fixture()
agent.on('initialize', (frame) =>
agent.reply(frame, { protocolVersion: 1, authMethods: [method] })
)
agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required'))
await expect(runtime.start(startOptions)).rejects.toMatchObject({
name: 'AcpAuthRequiredError',
authMethods: [method]
})
expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new'])
})
it.each(['prompt', 'cancel', 'setMode', 'setModel', 'setConfigOption'] as const)(
'rejects %s before start through its promise',
async (method) => {
const { runtime } = fixture()
let pending: Promise<unknown> | undefined
expect(() => {
switch (method) {
case 'prompt':
pending = runtime.prompt([...prompt])
break
case 'cancel':
pending = runtime.cancel()
break
case 'setMode':
pending = runtime.setMode('plan')
break
case 'setModel':
pending = runtime.setModel('model')
break
case 'setConfigOption':
pending = runtime.setConfigOption('thinking', 'high')
break
}
}).not.toThrow()
await expect(pending).rejects.toThrow('ACP session has not started')
}
)
it('answers a handled void vendor request with null', async () => {
const handler = vi.fn()
const { agent } = fixture({ onRequest: handler })
expect(await agent.request(7, '_vendor/ack', {})).toMatchObject({ id: 7, result: null })
expect(handler).toHaveBeenCalledTimes(1)
})
it('ignores duplicate incoming ids without answering the original request', async () => {
const answer = deferred<unknown>()
const handler = vi.fn(() => answer.promise)
@@ -168,109 +115,6 @@ describe('ACP caller-owned waits', () => {
expect(agent.frames).toHaveLength(1)
})
it.each([{ code: 'E1', message: 'boom' }, { code: -1, message: null }, null])(
'rejects a malformed error reply immediately with its raw error: %j',
async (error) => {
const { agent, runtime } = fixture()
agent.on('initialize', (frame) => {
agent.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: frame.id, error })}\n`)
})
await expect(runtime.initialize()).rejects.toMatchObject({ code: -32603, data: error })
}
)
it('maps authentication-required and retries only the explicitly chosen advertised method once', async () => {
const { agent, runtime } = fixture()
const authMethods = [{ id: 'login', name: 'Login' }]
agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1, authMethods }))
agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required'))
agent.on('authenticate', (frame) => agent.reply(frame, {}))
await expect(runtime.start({ ...startOptions, authMethodId: 'login' })).rejects.toMatchObject({
name: 'AcpAuthRequiredError',
authMethods
})
expect(agent.frames.map((frame) => frame.method)).toEqual([
'initialize',
'session/new',
'authenticate',
'session/new'
])
})
it('rejects overflowing peer timeouts and supports an explicit unlimited wait', async () => {
vi.useFakeTimers()
const agent = new AcpScriptedAgent()
opened.push(agent)
expect(
() => new AcpJsonRpcPeer(agent.stdout, agent.stdin, {}, { requestTimeoutMs: 3_000_000_000 })
).toThrow('timer durations')
const peer = new AcpJsonRpcPeer(agent.stdout, agent.stdin, {}, { requestTimeoutMs: 20 })
opened.push(peer)
await expect(peer.request('overflow', {}, { timeoutMs: 3_000_000_000 })).rejects.toThrow(
'timer durations'
)
const pending = peer.request('wait', {}, { timeoutMs: null })
const rejected = expect(pending).rejects.toBeInstanceOf(AcpConnectionClosedError)
await vi.advanceTimersByTimeAsync(3_000_000_000)
expect(peer.closed).toBe(false)
expect(agent.frames.map((frame) => frame.method)).toEqual(['wait'])
peer.close()
await rejected
})
it('bounds each start step on its own, never by the peer default', async () => {
vi.useFakeTimers()
const { agent, runtime } = fixture({ peer: { requestTimeoutMs: 20 } })
agent.on('initialize', () => {})
const initialized = runtime.initialize()
await vi.advanceTimersByTimeAsync(59_000)
agent.reply(agent.frames[0], { protocolVersion: 1 })
await initialized
const authenticated = runtime.authenticate('caller-chosen')
await vi.advanceTimersByTimeAsync(119_000)
agent.reply(agent.frames[1], {})
await expect(authenticated).resolves.toEqual({})
agent.on('session/new', () => {})
const started = expect(runtime.start(startOptions)).rejects.toBeInstanceOf(
AcpRequestTimeoutError
)
await vi.advanceTimersByTimeAsync(300_000)
await started
})
it('lets request owners withdraw vendor hooks after cancel', async () => {
const withdrawal = new AbortController()
const { agent, runtime } = fixture({
onRequest: (method) =>
new Promise((resolve, reject) => {
if (method !== '_vendor/silent') {
withdrawal.signal.addEventListener('abort', () =>
method === '_vendor/plan'
? resolve({ outcome: 'abandoned' })
: reject(new AcpRpcError(-32800, 'stop'))
)
}
})
})
agent.on('session/prompt', (frame) =>
agent.on('session/cancel', () => agent.reply(frame, { stopReason: 'cancelled' }))
)
await runtime.start(startOptions)
const pending = runtime.prompt([...prompt])
const question = agent.request('question', '_vendor/question', {})
const plan = agent.request('plan', '_vendor/plan', {})
void agent.request('silent', '_vendor/silent', {})
await tick()
await runtime.cancel()
withdrawal.abort()
expect(await question).toMatchObject({ error: { code: -32800 } })
expect(await plan).toMatchObject({ result: { outcome: 'abandoned' } })
await pending
await tick()
expect(agent.frames.filter((frame) => frame.id === 'plan')).toHaveLength(1)
expect(agent.frames.some((frame) => frame.id === 'silent')).toBe(false)
})
it('keeps a handler answer that finishes saving after its owner withdraws requests', async () => {
const withdrawal = new AbortController()
const planEntered = deferred<void>()
@@ -1,106 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AcpSessionRuntime, type AcpSessionEvent } from './acp-session-runtime'
import { AcpScriptedAgent } from './acp-scripted-agent.test-support'
const opened: { close: () => void }[] = []
function fixture() {
const agent = new AcpScriptedAgent()
const diagnostic = vi.fn()
const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, { onDiagnostic: diagnostic })
opened.push(agent, runtime)
agent.on('initialize', (frame) =>
agent.reply(frame, { protocolVersion: 1, agentCapabilities: { vendor: true } })
)
agent.on('session/new', (frame) => agent.reply(frame, { sessionId: 'session-1', vendor: true }))
return { agent, runtime, diagnostic }
}
afterEach(() =>
opened
.splice(0)
.toReversed()
.forEach((resource) => resource.close())
)
describe('ACP session update compatibility', () => {
it.each([
['known', { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }],
// Generated enums accept future values.
['known', { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' }],
['known', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }],
[
'known',
{
sessionUpdate: 'plan',
entries: [{ content: 'Ship', priority: 'urgent', status: 'blocked' }]
}
],
['unrecognized', { sessionUpdate: 'vendor_usage', tokens: 1 }],
[
'unrecognized',
{ sessionUpdate: 'agent_message_chunk', content: { type: 'reasoning_summary', text: 'x' } }
]
] as const)(
'delivers %s updates without losing their original fields (%j)',
async (kind, update) => {
const { agent, runtime, diagnostic } = fixture()
const events: AcpSessionEvent[] = []
runtime.subscribe((event) => events.push(event))
await runtime.start({ cwd: '/runtime/project', mcpServers: [] })
const notification = { sessionId: 'session-1', update, vendor: 'retained' }
agent.notify('session/update', notification)
agent.notify('session/update', notification)
expect(events).toHaveLength(2)
expect(events[0]).toEqual(
kind === 'known'
? { kind, notification }
: { kind, sessionId: 'session-1', raw: notification }
)
expect(diagnostic).toHaveBeenCalledTimes(kind === 'known' ? 0 : 1)
}
)
it('rejects only envelopes missing the session id or update discriminator', async () => {
const { agent, runtime } = fixture()
const received = vi.fn()
runtime.subscribe(received)
await runtime.start({ cwd: '/runtime/project', mcpServers: [] })
agent.notify('session/update', { update: { sessionUpdate: 'vendor' } })
agent.notify('session/update', { sessionId: 'session-1', update: {} })
expect(received).not.toHaveBeenCalled()
})
it('preserves extra session fields and exposes legacy model state as typed data', async () => {
const { agent, runtime } = fixture()
const models = {
currentModelId: 'model-1',
availableModels: [{ modelId: 'model-1', name: 'Model one' }]
}
agent.on('session/new', (frame) =>
agent.reply(frame, { sessionId: 'session-1', models, vendor: true })
)
const started = await runtime.start({ cwd: '/runtime/project', mcpServers: [] })
expect(started.response.models?.availableModels[0].modelId).toBe('model-1')
expect(started.response.vendor).toBe(true)
})
it('delivers updates even if diagnostic and earlier event callbacks throw', async () => {
const agent = new AcpScriptedAgent()
const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin, {
onDiagnostic: () => {
throw new Error('Diagnostic failed')
}
})
opened.push(agent, runtime)
runtime.subscribe(() => {
throw new Error('Listener failed')
})
const received = vi.fn()
runtime.subscribe(received)
const notification = { sessionId: 'session-1', update: { sessionUpdate: 'vendor' } }
agent.notify('session/update', notification)
expect(received).toHaveBeenCalledWith({
kind: 'unrecognized',
sessionId: 'session-1',
raw: notification
})
})
})
-104
View File
@@ -1,104 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import { z } from 'zod'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import type { AcpScriptedAgent } from './acp-scripted-agent.test-support'
import {
GROK_CONFIG_OPTIONS,
openAcpAdapterRig,
PROVIDER_SESSION
} from './acp-structured-adapter.test-support'
afterEach(async () => {
await closeProviderTimelineRigs()
})
const METHOD = z.object({ methodId: z.string() })
const BOTH = [
{ id: 'xai.api_key', name: 'API key' },
{ id: 'cached_token', name: 'Cached sign-in' }
]
function requiresSignIn(agent: AcpScriptedAgent): void {
let signedIn = false
agent.on('authenticate', (frame) => {
signedIn = true
agent.reply(frame, {})
})
const opens = (method: string, result: unknown) =>
agent.on(method, (frame) =>
signedIn ? agent.reply(frame, result) : agent.fail(frame, -32000, 'Authentication required')
)
opens('session/new', { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS })
opens('session/load', { configOptions: GROK_CONFIG_OPTIONS })
}
async function signInWith(options: {
env: Record<string, string>
authMethods: { id: string; name: string }[]
resume?: boolean
}) {
const rig = await openAcpAdapterRig({
launch: {
env: options.env,
...(options.resume
? {
resume: {
sessionId: PROVIDER_SESSION,
key: 'acp-key',
mayBeUnsaved: () => false,
unannouncedLosses: () => []
}
}
: {})
},
initialize: { authMethods: options.authMethods },
script: requiresSignIn
})
await rig.acquire()
const methods = rig.sent('authenticate').map((frame) => METHOD.parse(frame.params).methodId)
return { rig, acquired: rig.openFailure() ?? rig.started(), methods }
}
describe('Grok sign-in on its own machine', () => {
it("uses the API key in Grok's launch environment when Grok offers that method", async () => {
const { acquired, methods } = await signInWith({
env: { XAI_API_KEY: 'xai-1' },
authMethods: BOTH
})
expect(methods).toEqual(['xai.api_key'])
expect(acquired).toMatchObject({ link: { origin: 'created' } })
})
it('uses the cached sign-in without a key, or when Grok does not offer the key method', async () => {
const noKey = await signInWith({ env: {}, authMethods: BOTH })
expect(noKey.methods).toEqual(['cached_token'])
expect(noKey.acquired).toMatchObject({ link: { origin: 'created' } })
const keyNotOffered = await signInWith({
env: { XAI_API_KEY: 'xai-1' },
authMethods: [{ id: 'cached_token', name: 'Cached sign-in' }]
})
expect(keyNotOffered.methods).toEqual(['cached_token'])
expect(keyNotOffered.acquired).toMatchObject({ link: { origin: 'created' } })
})
it('reports Grok signed out when it offers neither, sending no sign-in', async () => {
const { acquired, methods } = await signInWith({
env: { XAI_API_KEY: 'xai-1' },
authMethods: [{ id: 'browser', name: 'Browser' }]
})
expect(methods).toEqual([])
expect(acquired).toMatchObject({ reason: 'notSignedIn' })
})
it('signs in the same way when it reopens a saved session', async () => {
const { rig, acquired, methods } = await signInWith({
env: { XAI_API_KEY: 'xai-1' },
authMethods: BOTH,
resume: true
})
expect(methods).toEqual(['xai.api_key'])
expect(acquired).toMatchObject({ link: { origin: 'resumed' } })
expect(rig.sent('session/new')).toEqual([])
})
})
@@ -1,33 +0,0 @@
// Bundled relative-source imports must not reach Electron; external package internals are excluded.
import { resolve } from 'node:path'
import { build } from 'esbuild'
import { describe, expect, it } from 'vitest'
describe('ACP structured lane', () => {
it('bundles the adapter, launch resolution and agent rows without relative-source Electron imports', async () => {
const result = await build({
entryPoints: [
resolve(__dirname, 'acp-structured-session-adapter.ts'),
resolve(__dirname, 'acp-structured-launch-resolution.ts'),
resolve(__dirname, 'acp-agent-connection.ts'),
resolve(__dirname, 'acp-launch-specs.ts')
],
bundle: true,
write: false,
outdir: 'out',
platform: 'node',
format: 'esm',
target: 'node22',
packages: 'external',
metafile: true,
logLevel: 'silent'
})
const imports = Object.values(result.metafile.outputs).flatMap((output) =>
output.imports.map((entry) => entry.path)
)
expect(imports.filter((path) => path === 'electron' || path.startsWith('electron/'))).toEqual(
[]
)
}, 60_000)
})
@@ -1,108 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import { z } from 'zod'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { ACP_LAUNCH_SPECS, type AcpLaunchSpec } from './acp-launch-specs'
import {
openAcpAdapterRig,
PROVIDER_SESSION,
sendHello
} from './acp-structured-adapter.test-support'
const promptMeta = z.object({ _meta: z.object({ promptId: z.string() }).optional() })
afterEach(closeProviderTimelineRigs)
function specFor(agent: string): AcpLaunchSpec {
const spec = ACP_LAUNCH_SPECS.find((entry) => entry.agent === agent)
if (!spec) {
throw new Error(`${agent} launch specification missing`)
}
return spec
}
async function failFirstPrompt(
spec: AcpLaunchSpec,
error: { code: number; message: string; data?: unknown },
beforeTurn = false
) {
const rig = await openAcpAdapterRig({
spec,
script: (agent) =>
agent.on('session/prompt', (frame) => {
if (!beforeTurn) {
agent.notify('session/update', {
sessionId: PROVIDER_SESSION,
_meta: promptMeta.parse(frame.params)._meta,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'started' }
}
})
}
agent.fail(frame, error.code, error.message, error.data)
})
})
await rig.acquire()
await sendHello(rig, 'first')
await rig.settle()
const failures = (await rig.rig.rows()).flatMap(({ body }) =>
body.kind === 'status' && body.tone === 'error' ? [body] : []
)
return { rig, failures }
}
const WORDS = 'Model quota exceeded for this workspace; try again in 5 minutes.'
// OpenCode 1.18.31 provider-error data contains metadata, not user-facing failure text.
const OPENCODE_PROVIDER_ERROR = {
code: -32603,
message: 'Internal error: CAPTURE_PROVIDER_400',
data: { service: 'session', errorName: 'APIError' }
}
describe.each(['opencode', 'grok'])('%s prompt failure detail', (agent) => {
const spec = specFor(agent)
it.each([
['data.details', { code: -32603, message: 'Internal error', data: { details: WORDS } }, WORDS],
['string data', { code: -32603, message: 'Internal error', data: WORDS }, WORDS],
['no data', { code: -32603, message: 'Internal error' }, 'Internal error'],
['metadata-only data', OPENCODE_PROVIDER_ERROR, 'Internal error: CAPTURE_PROVIDER_400']
])('shows the agent words from %s', async (_label, error, text) => {
const { failures } = await failFirstPrompt(spec, error)
expect(failures).toHaveLength(1)
expect(failures[0]?.text).toBe(text)
expect(failures[0]?.failure?.kind).not.toBe('notSignedIn')
})
it('quotes data.details after the sign-in sentence', async () => {
const { failures } = await failFirstPrompt(spec, {
code: -32000,
message: 'Authentication required',
data: { details: WORDS }
})
expect(failures).toHaveLength(1)
expect(failures[0]?.failure).toMatchObject({ kind: 'notSignedIn' })
expect(failures[0]?.text).toMatch(/^Sign in to .+\./)
expect(failures[0]?.text).toContain(WORDS)
expect(failures[0]?.text).not.toContain('Authentication required')
})
})
describe('prompt refused before its turn began', () => {
const spec = specFor('grok')
it.each([
[{ code: -32603, message: 'Internal error', data: { details: WORDS } }, WORDS],
[OPENCODE_PROVIDER_ERROR, 'Internal error: CAPTURE_PROVIDER_400']
])('quotes the agent words: %j', async (error, text) => {
const { rig, failures } = await failFirstPrompt(spec, error, true)
expect(failures).toHaveLength(0)
const rejected = rig.settled.find((item) => 'state' in item && item.state === 'rejected')
expect(rejected).toMatchObject({
clientMessageId: 'first',
rejection: expect.objectContaining({
kind: 'providerRejected',
detail: { text, audience: 'person' }
})
})
})
})
@@ -1,103 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
closeProviderTimelineRigs,
openProviderTimelineRig
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event'
import { AcpTimelineTranslator } from './acp-timeline-translator'
afterEach(closeProviderTimelineRigs)
async function genericRig() {
const rig = await openProviderTimelineRig()
const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' })
const apply = (events: ProviderTimelineEvent[]) => {
for (const event of events) {
expect(rig.assembler.apply(event).admission.accepted).toBe(true)
}
}
const update = (update: unknown) =>
apply(translator.notification('session/update', { sessionId: 'provider-1', update }, 1100))
return { rig, translator, apply, update }
}
describe('ACP values newer than this build', () => {
it('keeps a tool with a newer kind or status as a tool row in its last known state', async () => {
const { rig, translator, apply, update } = await genericRig()
apply(translator.openPrompt('send-1', 1000).events)
update({
sessionUpdate: 'tool_call',
toolCallId: 't1',
title: 'Open page',
kind: 'browse',
status: 'pending'
})
update({ sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'paused' })
const running = (await rig.rows()).find((row) => row.body.kind === 'tool-call')?.body
expect(running).toMatchObject({ name: 'Open page', state: 'running' })
update({ sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'completed' })
apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200))
const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn')
expect(rows.map((row) => row.body)).toEqual([
expect.objectContaining({ kind: 'tool-call', state: 'completed' })
])
})
it('reads a plan entry with a newer status or priority as not done', async () => {
const { rig, translator, apply, update } = await genericRig()
apply(translator.openPrompt('send-1', 1000).events)
update({
sessionUpdate: 'plan',
entries: [{ content: 'Wait', status: 'blocked', priority: 'urgent' }]
})
apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200))
expect(
(await rig.rows()).filter((row) => row.body.kind !== 'turn').map((row) => row.body)
).toEqual([expect.objectContaining({ presentation: 'plan-document', text: '- [ ] Wait' })])
})
it('ends a turn with a newer stop reason without inventing an outcome', async () => {
const { rig, translator, apply } = await genericRig()
apply(translator.openPrompt('send-1', 1000).events)
apply(translator.promptResult('send-1', { stopReason: 'paused_for_review' }, 1200))
const [turn] = await rig.turns()
expect(turn).toMatchObject({ state: 'completed' })
expect(turn).not.toHaveProperty('outcome')
expect((await rig.rows()).filter((row) => row.body.kind === 'status')).toEqual([])
})
it('shows a permission the runtime could only partly read, with what it could read', async () => {
const { rig, translator, apply } = await genericRig()
apply(translator.openPrompt('send-1', 1000).events)
const request = translator.request(
'session/request_permission',
{
sessionId: 'provider-1',
toolCall: { toolCallId: 't1', title: 42 },
options: [{ optionId: 'allow', kind: 'allow_for_project' }, { name: 'no id' }]
},
7
)
apply(request.events)
expect(request.presentation?.body).toMatchObject({
kind: 'approval',
title: 'Permission requested',
options: [{ id: 'allow', label: 'allow' }]
})
expect(request.presentation?.reply({ kind: 'option', optionId: 'allow' })).toEqual({
outcome: { outcome: 'selected', optionId: 'allow' }
})
expect((await rig.rows()).map((row) => row.body.kind)).toEqual(['turn', 'approval'])
expect(() =>
translator.request(
'session/request_permission',
{
sessionId: 'provider-1',
toolCall: {},
options: [{ optionId: 'allow', kind: 'allow_once' }]
},
8
)
).toThrow('Invalid ACP permission request')
})
})
@@ -1,181 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
closeProviderTimelineRigs,
openProviderTimelineRig
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event'
import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect'
import { AcpTimelineTranslator } from './acp-timeline-translator'
import { AcpAgentError } from './acp-errors'
afterEach(closeProviderTimelineRigs)
async function openCodeRig() {
const rig = await openProviderTimelineRig()
const translator = new AcpTimelineTranslator({
sessionId: 'provider-1',
dialect: OPENCODE_ACP_DIALECT
})
const apply = (events: ProviderTimelineEvent[]) => {
for (const event of events) {
expect(rig.assembler.apply(event).admission.accepted).toBe(true)
}
}
const update = (body: Record<string, unknown>, at: number) =>
apply(translator.notification('session/update', { sessionId: 'provider-1', update: body }, at))
apply(translator.openPrompt('send-1', 1000).events)
return { rig, translator, apply, update }
}
// OpenCode puts shell output and exit status in rawOutput.metadata.
const bashStart = {
sessionUpdate: 'tool_call',
toolCallId: 'call-1',
title: 'List files',
kind: 'execute',
status: 'pending',
rawInput: { command: 'ls missing', description: 'List files' }
}
describe('OpenCode over ACP', () => {
it.each([
{ errorName: 'provider.quota', message: undefined },
{ errorName: 'provider.rate-limit', retryAfterMs: 60_000, message: undefined },
...[
'Usage limit reached for 5 hours. Your limit will reset at 2026-10-01 00:00:00',
'Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-10-01 00:00:00'
].map((message) => ({ errorName: 'provider.quota', message }))
])('shows usage limits for $errorName', async ({ message: detail, ...data }) => {
const { rig, translator, apply } = await openCodeRig()
const message = `Internal error: ${detail ?? 'Rate limit exceeded. Please try again later.'}`
apply(
translator.promptFailed(
'send-1',
new AcpAgentError(-32603, message, {
service: 'session',
...data
}),
1001
)
)
const statuses = (await rig.rows()).filter((row) => row.body.kind === 'status')
expect(statuses.map((row) => row.body)).toEqual([
expect.objectContaining({
kind: 'status',
tone: 'warning',
failure: {
kind: 'usageLimitReached',
detail: { text: message, audience: 'person' },
...('retryAfterMs' in data ? { usageLimit: { resetsAt: 61_001 } } : {})
}
})
])
expect((await rig.turns())[0]?.outcome).toBe('failure')
})
it.each([
{ status: 402, message: 'Request rejected' },
{ message: 'Payment Required' },
{ message: 'Insufficient balance or no resource package. Please recharge.' },
{ message: 'Credit limit exceeded.' },
{ message: 'Upstream request failed: Insufficient account funds' },
{ message: 'Upstream request failed: Account invoice is overdue' },
...[
'Please add or update your payment method to continue',
'Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.',
'[1309][Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.][20260929132151e73af01340d54b58]',
'Your current subscription plan does not yet include access to glm-5',
'Your enterprise package has expired. Please contact your enterprise administrator.'
].map((message) => ({ message })),
{
errorName: 'provider.free-limit',
message:
'Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models.'
}
])('keeps non-reset errors generic: $message', async ({ message: detail, ...data }) => {
const { rig, translator, apply } = await openCodeRig()
const message = `Internal error: ${detail}`
apply(
translator.promptFailed(
'send-1',
new AcpAgentError(-32603, message, {
service: 'session',
errorName: 'provider.quota',
...data
}),
1001
)
)
expect(
(await rig.rows()).filter((row) => row.body.kind === 'status').map((row) => row.body)
).toEqual([{ kind: 'status', tone: 'error', text: message }])
expect((await rig.turns())[0]?.outcome).toBe('failure')
})
it.each([
{
label: 'metadata output and exit fallback',
rawOutput: {
output: 'ls: missing: No such file or directory\n',
metadata: { output: 'ls: missing: No such file or directory\n', exit: 1 }
},
expected: { exitCode: 1, output: { head: 'ls: missing: No such file or directory\n' } }
},
{
label: 'explicit zero takes precedence over metadata exit three',
rawOutput: { exitCode: 0, metadata: { exit: 3 } },
expected: { exitCode: 0 }
},
{
label: 'plain output without metadata',
rawOutput: 'done',
expected: { output: { head: 'done' } }
}
])(
'keeps shell result semantics through the journal: $label',
async ({ rawOutput, expected }) => {
const { rig, update } = await openCodeRig()
update(bashStart, 1001)
update(
{
sessionUpdate: 'tool_call_update',
toolCallId: 'call-1',
status: 'completed',
rawOutput
},
1002
)
const tool = (await rig.rows()).find((row) => row.body.kind === 'tool-call')?.body
expect(tool).toMatchObject({ kind: 'tool-call', state: 'completed', ...expected })
}
)
it("shows OpenCode's own permission option names", async () => {
const { translator, apply, update } = await openCodeRig()
update(bashStart, 1001)
const request = translator.request(
'session/request_permission',
{
sessionId: 'provider-1',
toolCall: { toolCallId: 'call-1', title: 'ls missing', kind: 'execute' },
options: [
{ optionId: 'once', kind: 'allow_once', name: 'Allow once' },
{ optionId: 'always', kind: 'allow_always', name: 'Always allow' },
{ optionId: 'reject', kind: 'reject_once', name: 'Reject' }
]
},
0
)
apply(request.events)
expect(request.presentation?.body).toMatchObject({
options: [
{ id: 'once', label: 'Allow once' },
{ id: 'always', label: 'Always allow' },
{ id: 'reject', label: 'Reject' }
]
})
expect(request.presentation?.reply({ kind: 'option', optionId: 'always' })).toEqual({
outcome: { outcome: 'selected', optionId: 'always' }
})
})
})
-29
View File
@@ -18,26 +18,6 @@ const done = (promptId: string, stopReason = 'end_turn') => ({
afterEach(closeProviderTimelineRigs)
describe('ACP exact prompt identity', () => {
it.each(['task-completed-2', 'subagent-completed-2', 'notifications-9', 'future-wake'])(
'opens %s independently of a queued user prompt',
async (wake) => {
const f = await openAcpFixtureRig()
const lane = f.lane()
const prompt = lane.openPrompt('c1', 1000)
expect(prompt.events).toEqual([])
f.apply(lane.notification('session/update', chunk(wake, 'background'), 1001))
f.apply(lane.notification('_x.ai/session_notification', done(wake), 1002))
f.apply(lane.notification('session/update', chunk(prompt.promptId, 'user reply'), 1003))
f.apply(lane.notification('_x.ai/session_notification', done(prompt.promptId), 1004))
f.apply(lane.promptResult('c1', { stopReason: 'end_turn' }, 1005))
const rows = await f.rig.rows()
const messages = rows.filter((row) => row.body.kind === 'message')
expect(messages.map((row) => messageText(row.body))).toEqual(['background', 'user reply'])
expect(messages[0]?.turnScope).not.toEqual(messages[1]?.turnScope)
expect((await f.rig.turns()).map((turn) => turn.outcome)).toEqual(['success', 'success'])
}
)
it('keeps a streaming background turn open when the user sends and scopes its permission to the tool', async () => {
const f = await openAcpFixtureRig()
const lane = f.lane()
@@ -111,13 +91,4 @@ describe('ACP exact prompt identity', () => {
expect((await f.rig.turns()).map((turn) => turn.outcome)).toEqual(['failure', 'success'])
}
)
it('settles an RPC-only failure even if the provider never streamed a frame', async () => {
const f = await openAcpFixtureRig()
const lane = f.lane()
lane.openPrompt('c1', 1000)
f.apply(lane.promptFailed('c1', new AcpAgentError(-32603, 'Rejected'), 1001))
expect((await f.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'failure' })
expect(() => lane.openPrompt('c2', 1002)).not.toThrow()
})
})
-80
View File
@@ -1,80 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
loadGrokVisualsSkill,
loadOmpVisualsSkill,
loadOpenCodeVisualsSkill,
withOpenCodeSkillsPath,
type AcpVisualsSkillInput
} from './acp-visuals-skill'
const SKILL = { pluginDir: '/app/plugin', skillsRoot: '/app/plugin/skills' }
function input(overrides: Partial<AcpVisualsSkillInput> = {}): AcpVisualsSkillInput {
return {
skill: SKILL,
version: null,
env: {},
...overrides
}
}
describe('Grok visuals skill', () => {
it('names the plugin folder only on a release known to take --plugin-dir', async () => {
await expect(loadGrokVisualsSkill(input({ version: '1.0.46' }))).resolves.toEqual({
pluginDir: '/app/plugin'
})
await expect(loadGrokVisualsSkill(input({ version: '1.0.44' }))).resolves.toEqual({
pluginDir: '/app/plugin'
})
await expect(loadGrokVisualsSkill(input({ version: '1.0.43' }))).resolves.toBeNull()
await expect(loadGrokVisualsSkill(input({ version: null }))).resolves.toBeNull()
})
})
describe('OpenCode visuals skill', () => {
it('adds the skills root as the only inline config when the user has none', async () => {
await expect(loadOpenCodeVisualsSkill(input({ version: '2.0.14' }))).resolves.toEqual({
env: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ skills: { paths: [SKILL.skillsRoot] } }) }
})
})
it("keeps the user's own inline config and skill paths", () => {
const merged = withOpenCodeSkillsPath(
JSON.stringify({ model: 'm', skills: { paths: ['/mine'], urls: ['https://x'] } }),
SKILL.skillsRoot
)
expect(JSON.parse(merged!)).toEqual({
model: 'm',
skills: { paths: ['/mine', SKILL.skillsRoot], urls: ['https://x'] }
})
expect(
JSON.parse(withOpenCodeSkillsPath(JSON.stringify({ skills: ['/mine'] }), SKILL.skillsRoot)!)
).toEqual({ skills: ['/mine', SKILL.skillsRoot] })
})
it('leaves a config it cannot extend safely alone', async () => {
for (const content of ['{ // jsonc\n}', '[]', '{"skills": 3}', '{"skills": {"paths": "/x"}}']) {
expect(withOpenCodeSkillsPath(content, SKILL.skillsRoot)).toBeNull()
}
await expect(
loadOpenCodeVisualsSkill(
input({ version: '2.0.14', env: { OPENCODE_CONFIG_CONTENT: 'not json' } })
)
).resolves.toBeNull()
})
it("skips 1.x, where the inline list would replace the user's own skill paths", async () => {
await expect(loadOpenCodeVisualsSkill(input({ version: '1.18.31' }))).resolves.toBeNull()
await expect(loadOpenCodeVisualsSkill(input({ version: null }))).resolves.toBeNull()
})
})
describe('OMP visuals skill', () => {
it("names the plugin folder and leaves the user's own config files alone", async () => {
await expect(
loadOmpVisualsSkill(
input({ version: '17.0.5', env: { PI_CONFIG_FILES: '/user/overlay.yml' } })
)
).resolves.toEqual({ pluginDir: '/app/plugin' })
})
})
@@ -56,45 +56,6 @@ function fixture(deferred: boolean, backpressure: boolean) {
}
}
it.each([false, true])(
'releases the completed last drain closure while the peer remains usable (backpressure=%s)',
async (backpressure) => {
const state = fixture(false, backpressure)
try {
await state.peer.notify('first', { text: 'x'.repeat(256 * 1024) })
expect(state.output.writableLength).toBe(0)
expect(state.output.listenerCount('drain')).toBe(0)
await collect()
expect(state.refs.filter((ref) => ref.deref())).toHaveLength(0)
await state.peer.notify('next', {})
expect(state.writes).toEqual(['first', 'next'])
} finally {
state.close()
}
}
)
it('keeps the active write until stream completion then releases its drain closure', async () => {
const state = fixture(true, true)
try {
const first = state.peer.notify('first', {})
const second = state.peer.notify('second', {})
await collect()
expect(state.refs.filter((ref) => ref.deref())).toHaveLength(1)
expect(state.writes).toEqual(['first'])
state.complete()
await first
expect(state.writes).toEqual(['first', 'second'])
state.complete()
await second
await collect()
expect(state.refs.filter((ref) => ref.deref())).toHaveLength(0)
expect(state.output.listenerCount('drain')).toBe(0)
} finally {
state.close()
}
})
it('retires a closed peer drain closure after its late stream completion', async () => {
const state = fixture(true, true)
try {
@@ -1,124 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type ProbeResult = { code: number | null; stdout: string; timedOut: boolean }
type RunProcessStub = (spec: { program: string; args: string[] }) => Promise<ProbeResult>
// Mock shell probes so CI scheduling and profile failures cannot silently exercise the fallback.
const probe = vi.hoisted(() => ({ runProcess: vi.fn<RunProcessStub>() }))
vi.mock('@orca/process-host', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
runProcess: probe.runProcess
}))
const probeMock = probe.runProcess
const { adoptLoginShellAgentHomes, resolveLoginShellGrokHome, resolveLoginShellKiroHome } =
await import('./agent-home-login-shell')
function stubProbeOutput(stdout: string | ((script: string) => string)): void {
probeMock.mockImplementation(async ({ args }) => ({
code: 0,
timedOut: false,
stdout: typeof stdout === 'string' ? stdout : stdout(args[1] ?? '')
}))
}
function stubProbeFailure(error: Error & { killed?: boolean }): void {
probeMock.mockImplementation(async () => {
if (error.killed) {
return { code: null, timedOut: true, stdout: '' }
}
throw error
})
}
beforeEach(() => {
probeMock.mockReset()
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe.runIf(process.platform !== 'win32')('resolveLoginShellGrokHome', () => {
it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => {
vi.stubEnv('SHELL', '/bin/sh')
stubProbeOutput('/srv/grok///\n')
await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/srv/grok')
const { program: shell, args } = probeMock.mock.calls[0]?.[0] ?? {}
expect(shell).toBe('/bin/sh')
// `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test.
expect(args?.[0]).toBe('-c')
})
it('passes -lc to a login shell that supports it', async () => {
vi.stubEnv('SHELL', '/bin/zsh')
stubProbeOutput('/srv/grok\n')
await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/srv/grok')
const { program: shell, args } = probeMock.mock.calls[0]?.[0] ?? {}
expect(shell).toBe('/bin/zsh')
expect(args?.[0]).toBe('-lc')
})
it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => {
vi.stubEnv('SHELL', '/bin/sh')
stubProbeOutput('../relative\n')
await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok')
})
it.each([
new Error('spawn failed'),
Object.assign(new Error('spawn timed out'), { killed: true })
])('falls back when the probe fails or times out: %s', async (error) => {
vi.stubEnv('SHELL', '/bin/sh')
stubProbeFailure(error)
await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok')
})
})
describe.runIf(process.platform !== 'win32')('resolveLoginShellKiroHome', () => {
it('reads KIRO_HOME from the login shell', async () => {
vi.stubEnv('SHELL', '/bin/sh')
stubProbeOutput('/srv/kiro/\n')
await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/srv/kiro')
expect(probeMock.mock.calls[0]?.[0].args[1]).toContain('printenv KIRO_HOME')
})
it('falls back to ~/.kiro when KIRO_HOME is unset or the probe fails', async () => {
vi.stubEnv('SHELL', '/bin/sh')
stubProbeOutput('\n')
await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro')
stubProbeFailure(new Error('spawn failed'))
await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro')
})
})
describe.runIf(process.platform !== 'win32')('adoptLoginShellAgentHomes', () => {
it('adopts profile-only agent homes and keeps ones already set', async () => {
vi.stubEnv('SHELL', '/bin/zsh')
stubProbeOutput((script) => (script.includes('KIRO_HOME') ? '/srv/kiro\n' : '/srv/grok\n'))
const env: NodeJS.ProcessEnv = { GROK_HOME: '/already/grok' }
await adoptLoginShellAgentHomes(env)
expect(env).toEqual({ GROK_HOME: '/already/grok', KIRO_HOME: '/srv/kiro' })
expect(probeMock).toHaveBeenCalledTimes(1)
})
it('leaves the environment alone when the login shell has no override', async () => {
vi.stubEnv('SHELL', '/bin/zsh')
stubProbeOutput('\n')
const env: NodeJS.ProcessEnv = {}
await adoptLoginShellAgentHomes(env)
expect(env).toEqual({})
})
})
@@ -1,51 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
__resetBranchRenameFailureOutputForTests,
readBranchRenameFailureOutputForDisplay,
rememberBranchRenameFailureOutput
} from './branch-rename-failure-output'
const output = (label: string) => ({
label,
exitCode: 1,
stdout: '',
stderr: `${label} failed`
})
beforeEach(() => {
__resetBranchRenameFailureOutputForTests()
})
describe('branch rename failure output store', () => {
it('returns the formatted capture for a remembered worktree', () => {
rememberBranchRenameFailureOutput('wt-1', output('Pi'))
expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBe(
'Pi exited with code 1.\n\n[stderr]\nPi failed'
)
})
it('returns null for unknown worktrees and after clearing', () => {
expect(readBranchRenameFailureOutputForDisplay('wt-unknown')).toBeNull()
rememberBranchRenameFailureOutput('wt-1', output('Pi'))
rememberBranchRenameFailureOutput('wt-1', null)
expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBeNull()
})
it('evicts the least recently recorded worktree beyond the cap', () => {
for (let index = 0; index < 33; index += 1) {
rememberBranchRenameFailureOutput(`wt-${index}`, output(`Agent${index}`))
}
expect(readBranchRenameFailureOutputForDisplay('wt-0')).toBeNull()
expect(readBranchRenameFailureOutputForDisplay('wt-32')).not.toBeNull()
})
it('re-recording refreshes recency so the entry survives eviction', () => {
for (let index = 0; index < 32; index += 1) {
rememberBranchRenameFailureOutput(`wt-${index}`, output(`Agent${index}`))
}
rememberBranchRenameFailureOutput('wt-0', output('Agent0-again'))
rememberBranchRenameFailureOutput('wt-new', output('AgentNew'))
expect(readBranchRenameFailureOutputForDisplay('wt-0')).not.toBeNull()
expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBeNull()
})
})
@@ -22,24 +22,9 @@ describe('Cursor vendor hook replay guard', () => {
).toEqual([])
})
it.each([{}, { CURSOR_VERSION: CURSOR_ENV.CURSOR_VERSION }, { CURSOR_PROJECT_DIR: '/p' }])(
'still delivers Claude hooks when only part of the Cursor environment is present: %j',
async (env) => {
expect(
(await runVendorHook(getClaudeManagedScript('local', CLAUDE_SCRIPT_OPTIONS), env)).requests
).toEqual(['/hook/claude'])
}
)
it('keeps Cursor native hooks reporting in the same environment', async () => {
expect((await runVendorHook(getCursorManagedScript('local'), CURSOR_ENV)).requests).toEqual([
'/hook/cursor'
])
})
it('does not suppress a hook service that did not opt in to the Claude import guard', async () => {
expect((await runVendorHook(getClaudeManagedScript('local'), CURSOR_ENV)).requests).toEqual([
'/hook/claude'
])
})
})
@@ -1,105 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
import { hostWorktreeTarget } from '../../shared/worktree/host-qualified-identity'
import {
renameWorktreeFolderOnFirstWork,
type FirstWorkFolderRenameDeps
} from './first-work-folder-rename'
const REPO = { id: 'repo1', path: '/repos/orca', connectionId: null } as unknown as Repo
const SETTINGS = { nestWorkspaces: false, workspaceDir: '/ws' } as unknown as GlobalSettings
const OLD_ID = 'repo1::/ws/cunner'
const FOLDER_WORKSPACE_ID = 'repo1::/ws/cunner::workspace:12345678-1234-1234-1234-123456789abc'
function makeDeps(overrides: Partial<FirstWorkFolderRenameDeps> = {}): FirstWorkFolderRenameDeps {
return {
getRepo: vi.fn(() => REPO),
getSettings: vi.fn(() => SETTINGS),
migrateWorktreeIdentity: vi.fn(),
notifyWorktreeRenamed: vi.fn(),
pathExists: vi.fn(async () => false),
moveWorktree: vi.fn(async () => {}),
...overrides
}
}
describe('renameWorktreeFolderOnFirstWork', () => {
const originalPlatform = process.platform
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
})
it('moves the folder and migrates identity on the happy path', async () => {
const deps = makeDeps()
const result = await renameWorktreeFolderOnFirstWork(OLD_ID, 'worktree-creation-spinner', deps)
expect(result).toBe(true)
expect(deps.moveWorktree).toHaveBeenCalledWith(
'/repos/orca',
'/ws/cunner',
'/ws/worktree-creation-spinner'
)
expect(deps.migrateWorktreeIdentity).toHaveBeenCalledWith(
hostWorktreeTarget({ id: OLD_ID, hostId: 'local' }),
'repo1::/ws/worktree-creation-spinner'
)
expect(deps.notifyWorktreeRenamed).toHaveBeenCalledWith(
'repo1',
OLD_ID,
'repo1::/ws/worktree-creation-spinner'
)
// Migrate main-process state before the renderer re-keys and saves its layout.
expect(vi.mocked(deps.migrateWorktreeIdentity).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(deps.notifyWorktreeRenamed).mock.invocationCallOrder[0]!
)
})
it('preserves the folder-workspace instance suffix in the migrated identity', async () => {
const deps = makeDeps()
const result = await renameWorktreeFolderOnFirstWork(
FOLDER_WORKSPACE_ID,
'worktree-creation-spinner',
deps
)
expect(result).toBe(true)
expect(deps.migrateWorktreeIdentity).toHaveBeenCalledWith(
hostWorktreeTarget({ id: FOLDER_WORKSPACE_ID, hostId: 'local' }),
'repo1::/ws/worktree-creation-spinner::workspace:12345678-1234-1234-1234-123456789abc'
)
expect(deps.notifyWorktreeRenamed).toHaveBeenCalledWith(
'repo1',
FOLDER_WORKSPACE_ID,
'repo1::/ws/worktree-creation-spinner::workspace:12345678-1234-1234-1234-123456789abc'
)
})
it('skips (no move) when the destination already exists', async () => {
const deps = makeDeps({ pathExists: vi.fn(async () => true) })
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'taken', deps)).toBe(false)
expect(deps.moveWorktree).not.toHaveBeenCalled()
expect(deps.migrateWorktreeIdentity).not.toHaveBeenCalled()
})
it('skips remote worktrees without moving', async () => {
const deps = makeDeps({ getRepo: vi.fn(() => ({ ...REPO, connectionId: 'ssh1' })) })
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'fix-auth', deps)).toBe(false)
expect(deps.moveWorktree).not.toHaveBeenCalled()
})
it('skips runtime-owned worktrees without moving', async () => {
const deps = makeDeps({
getRepo: vi.fn(() => ({ ...REPO, executionHostId: 'runtime:gpu-vm' as const }))
})
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'fix-auth', deps)).toBe(false)
expect(deps.moveWorktree).not.toHaveBeenCalled()
})
it('returns false when the repo is unknown', async () => {
const deps = makeDeps({ getRepo: vi.fn(() => undefined) })
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'fix-auth', deps)).toBe(false)
expect(deps.moveWorktree).not.toHaveBeenCalled()
})
})
@@ -21,17 +21,6 @@ describe('Grok vendor hook replay guard', () => {
}
})
it('leaves non-Grok hook delivery unchanged', async () => {
for (const [source, script] of [
['claude', getClaudeManagedScript('local', CLAUDE_SCRIPT_OPTIONS)],
['cursor', getCursorManagedScript('local')]
]) {
expect((await runVendorHook(script, { GROK_HOOK_EVENT: '' })).requests).toEqual([
`/hook/${source}`
])
}
})
it.skipIf(process.platform === 'win32')(
'keeps Grok-replayed hooks out of the spool',
async () => {
@@ -1,12 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { SFTPWrapper } from 'ssh2'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
writeManagedScriptRemote,
writeTextFileRemoteAtomic
} from './installer-utils-remote'
import { writeHooksJsonRemote, writeManagedScriptRemote } from './installer-utils-remote'
type FakeFs = {
files: Map<string, string>
@@ -145,98 +140,6 @@ function createFakeSftp(
}
describe('installer-utils-remote', () => {
it('returns {} when settings.json does not exist on the remote', async () => {
const { sftp } = createFakeSftp()
const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')
expect(result).toEqual({})
})
it('returns null when settings.json is malformed JSON', async () => {
const { sftp, fs } = createFakeSftp()
fs.files.set('/home/u/.claude/settings.json', 'not json {{')
const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')
expect(result).toBeNull()
})
it('parses settings.json with one leading BOM', async () => {
const { sftp, fs } = createFakeSftp()
fs.files.set('/home/u/.cursor/hooks.json', '\uFEFF{"version":1,"hooks":{}}')
const result = await readHooksJsonRemote(sftp, '/home/u/.cursor/hooks.json')
expect(result).toEqual({ version: 1, hooks: {} })
})
it('rethrows non-ENOENT read errors so callers can distinguish I/O failures from parse failures', async () => {
const sftp = {
readFile: (_path: string, _enc: string, cb: (err: unknown) => void): void => {
// Why: SSH_FX_PERMISSION_DENIED (3) is a real I/O failure that should
// not collapse into the same null result the parse-error path uses.
cb({ code: 3, message: 'permission denied' })
}
} as unknown as SFTPWrapper
await expect(readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')).rejects.toMatchObject({
code: 3
})
})
it('times out remote reads that never call back', async () => {
vi.useFakeTimers()
try {
const sftp = {
readFile: vi.fn()
} as unknown as SFTPWrapper
const pending = readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')
let rejection: unknown = null
pending.catch((error) => {
rejection = error
})
await vi.advanceTimersByTimeAsync(9_999)
expect(rejection).toBeNull()
await vi.advanceTimersByTimeAsync(1)
expect(rejection).toBeInstanceOf(Error)
await expect(pending).rejects.toThrow('Timed out waiting for SFTP readFile')
} finally {
vi.useRealTimers()
}
})
it('atomically writes settings.json via tmp + rename', async () => {
const { sftp, fs } = createFakeSftp()
await writeHooksJsonRemote(sftp, '/home/u/.claude/settings.json', {
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'foo' }] }] }
})
expect(fs.files.has('/home/u/.claude/settings.json')).toBe(true)
expect(fs.dirs.has('/home/u/.claude')).toBe(true)
const contents = fs.files.get('/home/u/.claude/settings.json')!
const parsed = JSON.parse(contents)
expect(parsed.hooks.Stop[0].hooks[0].command).toBe('foo')
const tmp = Array.from(fs.files.keys()).find((k) => k.includes('.tmp'))
expect(tmp).toBeUndefined()
expect(fs.modes.get('/home/u/.claude/settings.json')).toBe(0o600)
const tempPath = fs.operations[0]?.path
expect(tempPath).toMatch(/^\/home\/u\/\.claude\/\.[^.]+\.tmp$/)
expect(fs.operations).toEqual([
{ kind: 'write', path: tempPath },
{ kind: 'chmod', path: tempPath },
{ kind: 'rename', path: tempPath, destination: '/home/u/.claude/settings.json' }
])
})
it('preserves existing config file mode across atomic replacement', async () => {
const { sftp, fs } = createFakeSftp()
const path = '/home/u/.codex/config.toml'
fs.files.set(path, 'old')
fs.modes.set(path, 0o640)
await writeTextFileRemoteAtomic(sftp, path, 'new')
expect(fs.files.get(path)).toBe('new')
expect(fs.modes.get(path)).toBe(0o640)
})
it('uses OpenSSH overwrite rename when an atomic write updates an existing file', async () => {
const { sftp, fs } = createFakeSftp({
plainRenameOverwrites: false,
@@ -268,35 +171,6 @@ describe('installer-utils-remote', () => {
expect(Array.from(fs.files.keys()).some((key) => key.includes('.tmp'))).toBe(false)
})
it('writes the managed script and chmods 0o755', async () => {
const { sftp, fs } = createFakeSftp()
await writeManagedScriptRemote(sftp, '/home/u/.orca/agent-hooks/claude-hook.sh', '#!/bin/sh\n')
expect(fs.files.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe('#!/bin/sh\n')
expect(fs.modes.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
})
it('replaces an existing managed script atomically via temp file rename', async () => {
const { sftp, fs } = createFakeSftp({
plainRenameOverwrites: false,
openSshRename: true
})
const path = '/home/u/.orca/agent-hooks/claude-hook.sh'
fs.files.set(path, 'old script')
await writeManagedScriptRemote(sftp, path, 'new script')
expect(fs.files.get(path)).toBe('new script')
expect(fs.modes.get(path)).toBe(0o755)
expect([...fs.files.keys()]).toEqual([path])
const tempPath = fs.operations[0]?.path
expect(tempPath).toMatch(/^\/home\/u\/\.orca\/agent-hooks\/\.[^.]+\.tmp$/)
expect(fs.operations).toEqual([
{ kind: 'write', path: tempPath },
{ kind: 'chmod', path: tempPath },
{ kind: 'openssh-rename', path: tempPath, destination: path }
])
})
it('leaves the existing managed script intact when temp write fails', async () => {
const { sftp, fs } = createFakeSftp({ failDotFileWrites: true })
const path = '/home/u/.orca/agent-hooks/claude-hook.sh'
@@ -81,15 +81,4 @@ describe.each([
expect(existsSync(aside)).toBe(false)
expect(readFileSync(tmp, 'utf8')).toBe('next build')
})
it('leaves a locked target in place when not asked to move it', async () => {
const { tmp, target, aside } = stage()
refusals.set(tmp, 1)
await expect(replace(tmp, target, false)).rejects.toMatchObject({ code: 'EPERM' })
expect(readFileSync(target, 'utf8')).toBe('running build')
expect(existsSync(aside)).toBe(false)
expect(readFileSync(tmp, 'utf8')).toBe('next build')
})
})
@@ -1,6 +1,3 @@
// A phone on a host with no window may show something other than main only where it then shows
// what the desktop window does; the window's side is pinned in headless-phone-window-parity.test.ts.
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../shared/constants'
import {
@@ -1,58 +0,0 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { useStore } from 'zustand'
import { createStore } from 'zustand/vanilla'
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../shared/constants'
import {
selectAppRootSurfacePetEnabled,
selectAppRootSurfaceTelemetryOptedIn,
selectAppRootSurfaceVoiceEnabled
} from './app-root-surface-settings'
type SurfaceState = Parameters<typeof selectAppRootSurfaceVoiceEnabled>[0]
describe('app root surface settings selectors', () => {
it('does not rerender for an unrelated settings replacement', () => {
const store = createStore<SurfaceState>(() => ({ settings: getDefaultSettings('/tmp') }))
let renderCount = 0
const view = renderHook(() => {
renderCount += 1
return {
voiceEnabled: useStore(store, selectAppRootSurfaceVoiceEnabled),
petEnabled: useStore(store, selectAppRootSurfacePetEnabled),
telemetryOptedIn: useStore(store, selectAppRootSurfaceTelemetryOptedIn)
}
})
expect(renderCount).toBe(1)
act(() => {
const settings = store.getState().settings!
store.setState({ settings: { ...settings, editorAutoSave: !settings.editorAutoSave } })
})
expect(renderCount).toBe(1)
expect(view.result.current.voiceEnabled).toBe(false)
expect(view.result.current.petEnabled).toBe(false)
expect(view.result.current.telemetryOptedIn).toBe('unknown')
view.unmount()
})
it('still rerenders when a setting used by a surface changes', () => {
const store = createStore<SurfaceState>(() => ({ settings: getDefaultSettings('/tmp') }))
let renderCount = 0
const view = renderHook(() => {
renderCount += 1
return useStore(store, selectAppRootSurfaceVoiceEnabled)
})
act(() => {
const settings = store.getState().settings!
store.setState({ settings: { ...settings, voice: { ...settings.voice!, enabled: true } } })
})
expect(renderCount).toBe(2)
view.unmount()
})
})
@@ -1,27 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as AppWindowChrome from './app-window-chrome'
async function loadChromeOnMac(): Promise<typeof AppWindowChrome> {
vi.resetModules()
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' })
return import('./app-window-chrome')
}
describe('macOS traffic-light inset', () => {
afterEach(() => vi.unstubAllGlobals())
it('reserves the traffic-light inset in the macOS desktop window', async () => {
const chrome = await loadChromeOnMac()
expect(chrome.hasMacTrafficLights).toBe(true)
expect(chrome.MAC_TRAFFIC_LIGHTS_WIDTH).toBe('80px')
})
// #19299: a browser tab has no native window controls to make room for.
it('reserves nothing in a paired web client on a Mac browser', async () => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
const chrome = await loadChromeOnMac()
expect(chrome.isMac).toBe(true)
expect(chrome.hasMacTrafficLights).toBe(false)
expect(chrome.MAC_TRAFFIC_LIGHTS_WIDTH).toBe('0px')
})
})
@@ -1,78 +0,0 @@
// Desktop startup oracle for headless-mobile-session-absorbed-rows.test.ts.
import { expect, it, vi } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import {
HEADLESS_PHONE_LAYOUT_CASES,
type PhoneLayoutView
} from '../../../shared/workspace-layout/headless-phone-layout.test-fixture'
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
import { createTestStore } from '../store/slices/store-test-helpers'
import {
bootWindowFromPartitions,
projectStartupWindowView
} from './startup-window-view.test-fixture'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
createStoreSessionMockApi()
/** Project terminal bindings in pane-tree order, matching projectStartupWindowView. */
function paneTerminals(node: unknown): (string | null)[] {
if (typeof node !== 'object' || node === null) {
return []
}
if ('pane' in node) {
return [typeof node.pane === 'string' ? node.pane : null]
}
return [
...('first' in node ? paneTerminals(node.first) : []),
...('second' in node ? paneTerminals(node.second) : [])
]
}
function windowView(view: Record<string, unknown>, worktreeIds: string[]): PhoneLayoutView {
const result: PhoneLayoutView = {}
for (const worktreeId of worktreeIds) {
const tabIds = Object.keys(view)
.filter((key) => key.startsWith(`${worktreeId} group `) && key.endsWith(' tabs'))
.flatMap((key) => (Array.isArray(view[key]) ? view[key].map(String) : []))
const panes = tabIds.flatMap((tabId) => {
const terminal = `${worktreeId} terminal ${tabId}`
if (!(`${terminal} panes` in view)) {
return []
}
const title = String(view[`${terminal} phone and CLI title`])
const terminals = paneTerminals(view[`${terminal} panes`])
return terminals.length === 0
? [`${tabId} (no layout yet) ${title}`]
: terminals.map((ptyId) => `${tabId} ${ptyId ?? '-'} ${title}`)
})
const active = view[`${worktreeId} active terminal`]
const groups = Object.keys(view)
.filter((key) => key.startsWith(`${worktreeId} group `) && key.endsWith(' tabs'))
.map((key) => {
const groupActive = view[key.replace(/ tabs$/, ' active tab')]
return {
tabs: Array.isArray(view[key]) ? view[key].map(String) : [],
active: typeof groupActive === 'string' ? groupActive : null
}
})
result[worktreeId] = { panes, active: typeof active === 'string' ? active : null, groups }
}
return result
}
it.each(HEADLESS_PHONE_LAYOUT_CASES)('window at startup: $name', async (scenario) => {
const state = await bootWindowFromPartitions(createTestStore(), [
{ hostId: LOCAL_EXECUTION_HOST_ID, session: scenario.build() }
])
expect(windowView(projectStartupWindowView(state), Object.keys(scenario.desktop))).toEqual(
scenario.desktop
)
})
@@ -1,46 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models'
const hoisted = vi.hoisted(() => ({
layoutOwnerIsMain: false,
showServerLayoutFrames: vi.fn(),
showStartupLayoutFrames: vi.fn()
}))
vi.mock('../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return hoisted.layoutOwnerIsMain
}
}))
vi.mock('../runtime/server-layout-frames', () => ({
showServerLayoutFrames: hoisted.showServerLayoutFrames
}))
vi.mock('../runtime/workspace-layout-frame-sync', () => ({
showStartupLayoutFrames: hoisted.showStartupLayoutFrames
}))
vi.mock('../runtime/sleeping-records-to-layout-owner', () => ({
followSleepingRecordsInLayoutOwner: vi.fn()
}))
describe('paired servers’ layout streams at restore', () => {
beforeEach(() => {
hoisted.showServerLayoutFrames.mockReset()
hoisted.showStartupLayoutFrames.mockReset()
})
it('open with the layout-owner switch on', () => {
hoisted.layoutOwnerIsMain = true
reconcileHydratedWorkspaceTabModels({ tabsByWorktree: { 'wt-a': [] } }, vi.fn())
expect(hoisted.showStartupLayoutFrames).toHaveBeenCalledTimes(1)
expect(hoisted.showServerLayoutFrames).toHaveBeenCalledTimes(1)
})
it('never open with the switch off, though restore reconciled', () => {
hoisted.layoutOwnerIsMain = false
const reconcile = vi.fn()
reconcileHydratedWorkspaceTabModels({ tabsByWorktree: { 'wt-a': [] } }, reconcile)
expect(reconcile).toHaveBeenCalledWith(['wt-a'])
expect(hoisted.showServerLayoutFrames).not.toHaveBeenCalled()
expect(hoisted.showStartupLayoutFrames).not.toHaveBeenCalled()
})
})
@@ -1,21 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models'
describe('reconcileHydratedWorkspaceTabModels', () => {
it('reconciles every workspace the session hydrated, in session order, in one call', () => {
const reconcile = vi.fn()
const reconciled = reconcileHydratedWorkspaceTabModels(
{ tabsByWorktree: { 'wt-a': [], 'wt-b': [], 'wt-c': [] } },
reconcile
)
expect(reconcile).toHaveBeenCalledTimes(1)
expect(reconcile.mock.calls[0]?.[0]).toEqual(['wt-a', 'wt-b', 'wt-c'])
expect(reconciled).toEqual(['wt-a', 'wt-b', 'wt-c'])
})
it('reconciles nothing for a session without terminal rows', () => {
const reconcile = vi.fn()
expect(reconcileHydratedWorkspaceTabModels({ tabsByWorktree: {} }, reconcile)).toEqual([])
expect(reconcile).not.toHaveBeenCalled()
})
})
@@ -38,30 +38,6 @@ describe('createShutdownCheckpointPersist', () => {
vi.restoreAllMocks()
})
it('keeps sleeping-capture diagnostics non-throwing for unstringifiable values', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const deps = makeDeps({
captureSleepingAgentSessions: () => {
throw Object.create(null)
}
})
expect(createShutdownCheckpointPersist(deps).run).not.toThrow()
expect(deps.stageBeforeUnloadSync).toHaveBeenCalledTimes(1)
vi.restoreAllMocks()
})
it('keeps the first full-staging failure a visible, retryable error (STA-5505)', () => {
const stageBeforeUnloadSync = vi.fn(() => {
throw new Error('sync IPC staging failed')
})
const { run: persist } = createShutdownCheckpointPersist(makeDeps({ stageBeforeUnloadSync }))
// A transient failure must not silently cost the full snapshot on attempt one.
expect(persist).toThrow('sync IPC staging failed')
expect(stageBeforeUnloadSync).toHaveBeenCalledTimes(1)
})
it('degrades to durable-only staging when full staging fails again on retry (STA-5505)', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const stageBeforeUnloadSync = vi.fn((args: { sessions: unknown[] }) => {
@@ -168,29 +144,4 @@ describe('createShutdownCheckpointPersist', () => {
expect(createShutdownCheckpointPersist(deps).run).toThrow('snapshot build failed')
expect(deps.stageBeforeUnloadSync).not.toHaveBeenCalled()
})
it('skips capture and stages empty sessions before hydration completes', () => {
const deps = makeDeps({ shouldCaptureSession: () => false })
createShutdownCheckpointPersist(deps).run()
expect(deps.captureTerminalBuffers).not.toHaveBeenCalled()
expect(deps.captureSleepingAgentSessions).not.toHaveBeenCalled()
expect(deps.stageBeforeUnloadSync).toHaveBeenCalledWith({
sessions: [],
ui: { activeView: 'workspace' }
})
})
it('forgets a failed attempt when an aborted shutdown resets the lifecycle', () => {
const stageBeforeUnloadSync = vi.fn(() => {
throw new Error('sync IPC staging failed')
})
const persist = createShutdownCheckpointPersist(makeDeps({ stageBeforeUnloadSync }))
expect(persist.run).toThrow('sync IPC staging failed')
persist.abandonAttempt()
expect(persist.run).toThrow('sync IPC staging failed')
expect(stageBeforeUnloadSync).toHaveBeenCalledTimes(2)
})
})
@@ -139,27 +139,4 @@ describe('shutdown checkpoint restart lifecycle', () => {
expect(harness.stageBeforeUnloadSync).toHaveBeenCalledTimes(2)
})
// Match the E2E checkpoint failure with its cause suffix (tests/e2e/update-install-renderer-checkpoint-recovery.spec.ts, STA-5668).
it('names the snapshot-build cause when dirty drafts block the checkpoint', async () => {
const snapshotFailure = "Cannot read properties of null (reading 'toLowerCase')"
vi.spyOn(console, 'error').mockImplementation(() => {})
const harness = createLifecycleHarness(
ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT,
{
buildSessionSnapshots: () => {
throw new Error(snapshotFailure)
},
hasDirtyOpenFiles: () => true
}
)
cleanupFns.push(harness.cleanup)
await expect(harness.prepare()).rejects.toThrow(
new Error(`Renderer shutdown checkpoint was not completed: ${snapshotFailure}`)
)
// Dirty drafts must block before any durable-only degrade stages over them.
expect(harness.stageBeforeUnloadSync).not.toHaveBeenCalled()
})
})
@@ -1,92 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createStore } from 'zustand/vanilla'
import {
resetStartupActionsSelectorCacheForTest,
selectStartupActions,
type StartupActions
} from './startup-actions-selector'
type StartupTestState = StartupActions & { publication: number }
function makeActions(): StartupActions {
return {
fetchReposForAllHosts: vi.fn(),
awaitLocalRepoCatalogSettlement: vi.fn(),
fetchProjectGroupsForAllHosts: vi.fn(),
fetchFolderWorkspacesForAllHosts: vi.fn(),
fetchAllWorktrees: vi.fn(),
fetchWorktrees: vi.fn(),
fetchWorktreeLineage: vi.fn(),
fetchOrcaProfiles: vi.fn(),
fetchSettings: vi.fn(),
awaitOwnerWorktreeVisibilityDefaultsHydration: vi.fn(),
fetchKeybindings: vi.fn(),
initGitHubCache: vi.fn(),
hydrateWorkspaceSession: vi.fn(),
hydrateTabsSession: vi.fn(),
hydrateEditorSession: vi.fn(),
hydrateBrowserSession: vi.fn(),
fetchBrowserSessionProfiles: vi.fn(),
reconnectPersistedTerminals: vi.fn(),
setTerminalStartupRestorationReady: vi.fn(),
setDeferredSshReconnectTargets: vi.fn(),
removeDeferredSshReconnectTarget: vi.fn(),
setSshConnectionState: vi.fn(),
hydratePersistedUI: vi.fn(),
setHydrationSucceeded: vi.fn(),
pruneLastVisitedTimestamps: vi.fn(),
seedActiveWorktreeLastVisitedIfMissing: vi.fn()
} as StartupActions
}
describe('startup action selector', () => {
beforeEach(() => {
resetStartupActionsSelectorCacheForTest()
})
it('reuses one action bundle across 1,000 repeated reads', () => {
const actions = makeActions()
const first = selectStartupActions(actions)
for (let read = 0; read < 1_000; read += 1) {
expect(selectStartupActions(actions)).toBe(first)
}
})
it('rebuilds the bundle when one action reference changes', () => {
const actions = makeActions()
const first = selectStartupActions(actions)
const replacement = { ...actions, fetchSettings: vi.fn() } as StartupActions
const next = selectStartupActions(replacement)
expect(next).not.toBe(first)
for (let read = 0; read < 1_000; read += 1) {
expect(selectStartupActions(replacement)).toBe(next)
}
})
it('keeps the selected projection stable across 1,000 unrelated publications', () => {
const actions = makeActions()
const store = createStore<StartupTestState>(() => ({ ...actions, publication: 0 }))
let publicationCount = 0
let projectionChangeCount = 0
let previousProjection = selectStartupActions(store.getState())
const unsubscribe = store.subscribe((state) => {
publicationCount += 1
const projection = selectStartupActions(state)
if (projection !== previousProjection) {
projectionChangeCount += 1
}
previousProjection = projection
})
for (let publication = 1; publication <= 1_000; publication += 1) {
store.setState({ publication })
}
expect(publicationCount).toBe(1_000)
expect(projectionChangeCount).toBe(0)
unsubscribe()
})
})
@@ -1,6 +1,5 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { StrictMode } from 'react'
import { Toaster } from 'sonner'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useProfileStateSaveDelayNotice } from './use-profile-state-save-delay-notice'
@@ -85,28 +84,6 @@ it('keeps the restored warning when an old shell dismissal arrives after remount
expect(reopened.queryByText(title)).toBeNull()
})
it('does not let StrictMode cleanup dismiss the initial delayed snapshot', async () => {
const view = render(
<StrictMode>
<Notice />
</StrictMode>
)
await advanceTimers()
await finishDismissal()
expect(listeners.size).toBe(1)
expect(view.queryAllByText(title)).toHaveLength(1)
})
it('lets the user close the warning with the app toaster close button', async () => {
const view = render(<Notice closeButton />)
await advanceTimers()
expect(view.queryByText(title)).not.toBeNull()
act(() => view.getByRole('button', { name: 'Close toast' }).click())
await finishDismissal()
expect(view.queryByText(title)).toBeNull()
})
it('keeps a new warning if recovery and another delay arrive before a dismissal frame', async () => {
const view = render(<Notice />)
await advanceTimers()
@@ -53,22 +53,6 @@ async function resolveSnapshot(delayed: boolean): Promise<void> {
})
}
it('restores a persistent warning from main and clears it on completion', async () => {
renderHook(useProfileStateSaveDelayNotice)
expect(subscribe.mock.invocationCallOrder[0]).toBeLessThan(read.mock.invocationCallOrder[0])
await resolveSnapshot(true)
expect(notice.warning).toHaveBeenCalledExactlyOnceWith(
'Profile storage is taking longer than usual',
{
id: undefined,
description: 'Further saves may be delayed while this operation finishes.',
duration: Infinity
}
)
publish(false)
expect(notice.dismiss).toHaveBeenLastCalledWith(notice.warning.mock.results[0].value)
})
it('does not let an old healthy snapshot erase a newly delayed save', async () => {
renderHook(useProfileStateSaveDelayNotice)
publish(true)
@@ -95,30 +79,3 @@ it('removes its listener and ignores snapshots or queued events after unmount',
expect(notice.warning).not.toHaveBeenCalled()
expect(notice.dismiss).not.toHaveBeenCalled()
})
it('rehydrates a still-delayed save after the app shell remounts', async () => {
const first = renderHook(useProfileStateSaveDelayNotice)
await resolveSnapshot(true)
first.unmount()
renderHook(useProfileStateSaveDelayNotice)
await act(async () => {
await snapshot.promise
})
expect(notice.warning).toHaveBeenCalledTimes(2)
expect(notice.warning.mock.results[0].value).not.toBe(notice.warning.mock.results[1].value)
expect(notice.dismiss).toHaveBeenCalledExactlyOnceWith(notice.warning.mock.results[0].value)
})
it('reuses one ID while delayed but gives the next delayed episode a new ID', () => {
renderHook(useProfileStateSaveDelayNotice)
publish(true)
const firstId = notice.warning.mock.results[0].value
publish(true)
expect(notice.warning.mock.calls[1][1].id).toBe(firstId)
publish(false)
publish(false)
expect(notice.dismiss).toHaveBeenCalledExactlyOnceWith(firstId)
publish(true)
expect(notice.warning.mock.calls[2][1].id).toBeUndefined()
expect(notice.warning.mock.results[2].value).not.toBe(firstId)
})
@@ -1,65 +0,0 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { useStore } from 'zustand'
import { createStore } from 'zustand/vanilla'
import { describe, expect, it, vi } from 'vitest'
import {
resetWindowVisibilityActionsSelectorCacheForTest,
selectWindowVisibilityActions,
type WindowVisibilityActions
} from './window-visibility-actions-selector'
function makeActions(): WindowVisibilityActions {
return {
refreshAllGitHub: vi.fn(),
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
bumpGitHubPRVisibleRefreshGeneration: vi.fn()
}
}
describe('window visibility action selector', () => {
it('reuses one action bundle across repeated store reads', () => {
resetWindowVisibilityActionsSelectorCacheForTest()
const actions = makeActions()
const first = selectWindowVisibilityActions(actions)
for (let read = 0; read < 1_000; read += 1) {
expect(selectWindowVisibilityActions(actions)).toBe(first)
}
})
it('rebuilds the bundle when an action reference changes', () => {
resetWindowVisibilityActionsSelectorCacheForTest()
const actions = makeActions()
const first = selectWindowVisibilityActions(actions)
const replacement = { ...actions, refreshAllGitHub: vi.fn() }
const next = selectWindowVisibilityActions(replacement)
expect(next).not.toBe(first)
expect(selectWindowVisibilityActions(replacement)).toBe(next)
})
it('does not rerender a subscriber for unrelated store writes', () => {
resetWindowVisibilityActionsSelectorCacheForTest()
const store = createStore<WindowVisibilityActions & { unrelated: number }>(() => ({
...makeActions(),
unrelated: 0
}))
let renderCount = 0
const view = renderHook(() => {
renderCount += 1
return useStore(store, selectWindowVisibilityActions)
})
const initialRenderCount = renderCount
act(() => {
for (let write = 1; write <= 1_000; write += 1) {
store.setState({ unrelated: write })
}
})
expect(renderCount).toBe(initialRenderCount)
view.unmount()
})
})
@@ -167,15 +167,6 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () =
vi.useRealTimers()
})
it('desktop restart alone does not rewrite the authority (control)', async () => {
const before = authority.get()
await flushDesktopDebounce()
const after = authority.get()
expect(after.hideSleepingWorkspaces).toBe(before.hideSleepingWorkspaces)
expect(after.hideDefaultBranchWorkspace).toBe(before.hideDefaultBranchWorkspace)
expect(after.hideCliCreatedWorkspaces).toBe(before.hideCliCreatedWorkspaces)
})
it('saves a revealed remote host after an unrelated older broadcast', async () => {
act(() => {
authority.set({ workspaceHostScope: 'local', visibleWorkspaceHostIds: ['local'] })
@@ -210,74 +201,6 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () =
expect(authority.get().workspaceHostScope).toBe('all')
})
it('persists a left sidebar close across an unrelated sync and restores it on startup', async () => {
act(() => {
store.getState().toggleSidebar()
authority.set({ sidebarWidth: 320 })
})
deliverBroadcasts()
expect(store.getState().sidebarOpen).toBe(false)
await flushDesktopDebounce()
expect(authority.get().sidebarOpen).toBe(false)
const restarted = createUIStore()
restarted.getState().hydratePersistedUI(authority.get(), 'startup')
expect(restarted.getState().sidebarOpen).toBe(false)
expect(restarted.getState().sidebarWidth).toBe(320)
})
it('persists a left sidebar reopen while the close acknowledgement is still pending', async () => {
holdAcks = true
act(() => store.getState().toggleSidebar())
await flushDesktopDebounce()
expect(authority.get().sidebarOpen).toBe(false)
act(() => store.getState().toggleSidebar())
deliverBroadcasts()
expect(store.getState().sidebarOpen).toBe(true)
await resolveAcks()
await flushDesktopDebounce()
await resolveAcks()
deliverBroadcasts()
expect(authority.get().sidebarOpen).toBe(true)
expect(store.getState().sidebarOpen).toBe(true)
expect(store.getState().persistedUIWriteInFlightCounts).toEqual({})
})
it('merges another client’s field without reverting a prior desktop write', async () => {
// Desktop turns on "hide default branch"; the write lands and broadcasts.
act(() => {
store.getState().setHideDefaultBranchWorkspace(true)
})
await flushDesktopDebounce()
expect(authority.get().hideDefaultBranchWorkspace).toBe(true)
deliverBroadcasts()
// Another client sends only its changed field.
authority.set({ hideSleepingWorkspaces: true })
expect(authority.get().hideSleepingWorkspaces).toBe(true)
expect(authority.get().hideDefaultBranchWorkspace).toBe(true)
deliverBroadcasts()
expect(store.getState().hideDefaultBranchWorkspace).toBe(true)
})
it('persists the desktop sleeping-workspaces toggle in the durable hide form', async () => {
// Verify mirror-to-wire inversion: showSleeping=false persists hideSleeping=true.
act(() => {
store.getState().setShowSleepingWorkspaces(false)
})
await flushDesktopDebounce()
expect(authority.get().hideSleepingWorkspaces).toBe(true)
act(() => {
store.getState().setShowSleepingWorkspaces(true)
})
await flushDesktopDebounce()
expect(authority.get().hideSleepingWorkspaces).toBe(false)
})
it('the desktop debounced writer preserves a concurrent client’s field', async () => {
// t=0: desktop toggles a desktop-only filter (disjoint from mobile fields).
act(() => {
@@ -347,86 +270,6 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () =
expect(store.getState().hideDefaultBranchWorkspace).toBe(false)
})
it('edits made during the ack window flush at debounce rate, not ack rate', async () => {
// Trailing rediffs must obey debounce, or dragging sends one write per IPC ack.
holdAcks = true
act(() => {
store.getState().setHideDefaultBranchWorkspace(true)
})
await flushDesktopDebounce()
const sendsAfterFirstFlush = setCallCount
act(() => {
store.getState().setHideCliCreatedWorkspaces(true)
})
act(() => {
store.getState().setHideDetachedHeadWorkspaces(true)
})
act(() => {
store.getState().setHideAutomationGeneratedWorkspaces(true)
})
await resolveAcks()
expect(setCallCount).toBe(sendsAfterFirstFlush)
await flushDesktopDebounce()
expect(setCallCount).toBe(sendsAfterFirstFlush + 1)
await resolveAcks()
await flushDesktopDebounce()
await resolveAcks()
expect(authority.get().hideDefaultBranchWorkspace).toBe(true)
expect(authority.get().hideCliCreatedWorkspaces).toBe(true)
expect(authority.get().hideDetachedHeadWorkspaces).toBe(true)
expect(authority.get().hideAutomationGeneratedWorkspaces).toBe(true)
})
it('converges when a remote client writes the same field during the ack window', async () => {
// Never fold an ack over a newer hydrated baseline; it would leave mirror/authority divergent without further traffic.
holdAcks = true
act(() => {
store.getState().setHideDefaultBranchWorkspace(true)
})
await flushDesktopDebounce()
// Another client writes the SAME field at the authority after us; both broadcasts
// (our echo, then the other client's) land before our ack does.
authority.set({ hideDefaultBranchWorkspace: false })
deliverBroadcasts()
await resolveAcks()
await flushDesktopDebounce()
await resolveAcks()
deliverBroadcasts()
await flushDesktopDebounce()
await resolveAcks()
deliverBroadcasts()
// Same-field conflicts may choose either writer, but settled mirrors must agree.
expect(store.getState().hideDefaultBranchWorkspace).toBe(
authority.get().hideDefaultBranchWorkspace
)
})
it('a rejected write folds nothing and re-flushes with the next change', async () => {
rejectSets = true
act(() => {
store.getState().setHideDefaultBranchWorkspace(true)
})
await flushDesktopDebounce()
expect(authority.get().hideDefaultBranchWorkspace).toBe(false)
expect(store.getState().persistedUIWriteInFlightCounts).toEqual({})
rejectSets = false
act(() => {
store.getState().setHideCliCreatedWorkspaces(true)
})
await flushDesktopDebounce()
await flushDesktopDebounce()
expect(authority.get().hideDefaultBranchWorkspace).toBe(true)
expect(authority.get().hideCliCreatedWorkspaces).toBe(true)
})
it('a terminal rejection leaves one dirty field without an automatic retry loop', async () => {
// Keep rejection dirty for a later edit without scheduling an endless trailing retry loop.
rejectSets = true
@@ -563,19 +406,6 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () =
expect(authority.get().hideDefaultBranchWorkspace).toBe(true)
})
it('overlapping in-flight writes on one field decrement, not clear, the marker', () => {
// Two overlapping writes require counts: the first ack must not unpin the second.
const s = store.getState()
s.notePersistedUIWriteStarted(['hideDefaultBranchWorkspace'])
s.notePersistedUIWriteStarted(['hideDefaultBranchWorkspace'])
store.getState().notePersistedUIWriteSettled(['hideDefaultBranchWorkspace'], null)
expect(store.getState().persistedUIWriteInFlightCounts).toEqual({
hideDefaultBranchWorkspace: 1
})
store.getState().notePersistedUIWriteSettled(['hideDefaultBranchWorkspace'], null)
expect(store.getState().persistedUIWriteInFlightCounts).toEqual({})
})
it('desktop and another client changing the same field converge on the newest write', async () => {
act(() => {
store.getState().setHideDefaultBranchWorkspace(true)
@@ -60,48 +60,6 @@ describe('resolveAgentAttention', () => {
expect(sink.calls).toEqual([])
})
it('rejects a subject with no live session and no fresh activity evidence', () => {
const decision = resolveAgentAttention(
completion(),
makeSurface({ hasLiveSession: () => false })
)
expect(decision).toEqual({ admitted: false, cause: 'no-live-session' })
})
it('admits a dead surface when fresh activity evidence stands in for liveness', () => {
const admitSurface = vi.fn(() => ({ admitted: true, groupId: GROUP }) as const)
const decision = resolveAgentAttention(
completion({ hasFreshActivityEvidence: true }),
makeSurface({ hasLiveSession: () => false, admitSurface })
)
expect(decision.admitted).toBe(true)
// The surface must be told which evidence admitted the event so it can pick its gate.
expect(admitSurface).toHaveBeenCalledWith(
{ workspaceId: WORKSPACE, surfaceKey: SUBJECT },
{ hasLiveSession: false, hasFreshActivityEvidence: true }
)
})
it('rejects a superseded surface outright — no unread and no delivery', () => {
const decision = resolveAgentAttention(
completion(),
makeSurface({ admitSurface: () => ({ admitted: false, cause: 'superseded-surface' }) })
)
expect(decision).toEqual({ admitted: false, cause: 'superseded-surface' })
const sink = makeSink()
applyAgentAttention(decision, sink)
expect(sink.calls).toEqual([])
})
it('rejects a surface key that resolves to no container', () => {
const decision = resolveAgentAttention(
completion(),
makeSurface({ admitSurface: () => ({ admitted: false, cause: 'unknown-surface' }) })
)
expect(decision).toEqual({ admitted: false, cause: 'unknown-surface' })
})
it('admits a viewed surface for delivery but earns it no unread', () => {
const decision = resolveAgentAttention(
completion(),
@@ -123,19 +81,6 @@ describe('resolveAgentAttention', () => {
])
})
it('falls back to workspace visibility when the event names no surface', () => {
const isWorkspaceViewed = vi.fn(() => true)
const admitSurface = vi.fn()
const decision = resolveAgentAttention(
completion({ subject: { workspaceId: WORKSPACE } }),
makeSurface({ isWorkspaceViewed, admitSurface })
)
expect(decision).toMatchObject({ admitted: true, unread: null })
expect(isWorkspaceViewed).toHaveBeenCalledWith(WORKSPACE)
// No surface key means there is no address to validate.
expect(admitSurface).not.toHaveBeenCalled()
})
it('delivers a bell without validating the surface address or writing unread', () => {
const admitSurface = vi.fn()
const isSurfaceViewed = vi.fn()
@@ -151,20 +96,4 @@ describe('resolveAgentAttention', () => {
applyAgentAttention(decision, sink)
expect(sink.calls).toEqual([`deliver:${WORKSPACE}:${SUBJECT}`])
})
it('reports in-app workspace selection to the delivery owner', () => {
const decision = resolveAgentAttention(
completion(),
makeSurface({ isWorkspaceActive: (workspaceId) => workspaceId === WORKSPACE })
)
expect(decision).toMatchObject({ admitted: true, delivery: { workspaceIsActive: true } })
})
it('writes unread before requesting delivery so a suppressed banner still leaves a marker', () => {
const sink = makeSink()
applyAgentAttention(resolveAgentAttention(completion(), makeSurface()), sink)
expect(
sink.calls.indexOf(`unread:${WORKSPACE}:${SUBJECT}:${GROUP}:agent-completion`)
).toBeLessThan(sink.calls.indexOf(`deliver:${WORKSPACE}:${SUBJECT}`))
})
})
@@ -3,7 +3,6 @@ import {
emitAgentSubjectReads,
registerAgentSubjectReadCapture,
subscribeAgentSubjectReads,
type AgentSubjectRead,
type StructuredSubjectRead
} from './agent-subject-read-actions'
@@ -23,30 +22,6 @@ const structured: StructuredSubjectRead = {
}
describe('agent subject read batch index', () => {
it('shares one first-read-wins index without changing captured order or filtering', () => {
const first: AgentSubjectRead = { subjectKey: 'one' }
const duplicate: AgentSubjectRead = { subjectKey: 'one', structured }
const second: AgentSubjectRead = { subjectKey: 'two', structured }
const batches: {
reads: readonly AgentSubjectRead[]
index: ReadonlyMap<string, AgentSubjectRead>
}[] = []
stops.push(
subscribeAgentSubjectReads((reads, index) => batches.push({ reads, index })),
subscribeAgentSubjectReads((reads, index) => batches.push({ reads, index }))
)
emitAgentSubjectReads(['one', 'two'], [first, duplicate, { subjectKey: 'excluded' }, second])
expect(batches[0]?.reads).toEqual([first, duplicate, second])
expect(batches[0]?.index.size).toBe(2)
expect(batches[0]?.index.get('one')).toBe(first)
expect(batches[0]?.index.get('two')).toBe(second)
expect(batches[1]?.index).toBe(batches[0]?.index)
emitAgentSubjectReads(['two'], [second])
expect(batches[2]?.index).not.toBe(batches[0]?.index)
expect(batches[2]?.index.size).toBe(1)
expect(batches[2]?.index.get('two')).toBe(second)
})
it('captures explicit reads, isolates listener errors, and retires subscriptions', () => {
const capture = vi.fn(() => structured)
stops.push(registerAgentSubjectReadCapture('one', capture))
@@ -1,77 +0,0 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentProgressRing, formatAgentProgressPercent } from './AgentProgressRing'
import { AgentStateDot, agentStateProgressLabel } from './AgentStateDot'
describe('AgentProgressRing', () => {
it('exposes the percentage as a progressbar and fills the arc to it', () => {
const markup = renderToStaticMarkup(<AgentProgressRing percent={42.4} />)
expect(markup).toContain('role="progressbar"')
expect(markup).toContain('aria-valuenow="42"')
expect(markup).toContain('stroke-dasharray="42 100"')
})
it('keeps a tiny percentage visible but draws nothing at zero', () => {
expect(renderToStaticMarkup(<AgentProgressRing percent={1} />)).toContain(
'stroke-dasharray="8 100"'
)
expect(renderToStaticMarkup(<AgentProgressRing percent={0} />)).toContain(
'stroke-dasharray="0 100"'
)
})
it('takes an accessible name over the bare percentage', () => {
expect(
renderToStaticMarkup(<AgentProgressRing percent={42} label="Working · 42%" />)
).toContain('aria-label="Working · 42%"')
})
it('clamps the label to 0–100', () => {
expect(formatAgentProgressPercent(140)).toBe('100%')
expect(formatAgentProgressPercent(-3)).toBe('0%')
})
})
describe('AgentStateDot with program progress', () => {
const render = (state: Parameters<typeof AgentStateDot>[0]['state'], progress?: number) =>
renderToStaticMarkup(
<TooltipProvider>
<AgentStateDot state={state} progress={progress} />
</TooltipProvider>
)
it('swaps the spinner for a filling ring while working, announced once on the progressbar', () => {
const markup = render('working', 60)
expect(markup).toContain('data-agent-progress-ring')
expect(markup).not.toContain('data-agent-spinner')
expect(markup).toContain('text-agent-working')
expect(markup.match(/aria-label="Working · 60%"/g)).toHaveLength(1)
expect(markup).toMatch(/<svg[^>]*role="progressbar"[^>]*aria-label="Working · 60%"/)
})
it('draws the ring in the attention tone for every attention state', () => {
for (const state of ['waiting', 'blocked', 'permission'] as const) {
expect(render(state, 30)).toContain('text-agent-question')
}
})
it('ignores progress on finished states', () => {
expect(render('done', 100)).not.toContain('data-agent-progress-ring')
expect(render('working')).toContain('data-agent-spinner')
})
})
describe('agentStateProgressLabel', () => {
it('adds the percentage only where the dot draws a ring', () => {
expect(agentStateProgressLabel('working', 42)).toBe('Working · 42%')
expect(agentStateProgressLabel('waiting', 42)).toBe('Waiting for input · 42%')
expect(agentStateProgressLabel('done', 42)).toBe('Done')
expect(agentStateProgressLabel('working', undefined)).toBe('Working')
})
it('decorates a caller-supplied label', () => {
expect(agentStateProgressLabel('working', 7, 'Compiling')).toBe('Compiling · 7%')
expect(agentStateProgressLabel('unverifiable', 7, 'No update in 3m')).toBe('No update in 3m')
})
})
@@ -1,145 +0,0 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { AgentStateDot, agentStateLabel, type AgentDotState } from './AgentStateDot'
vi.mock('@/components/StateIndicatorTooltip', async () => {
const { createElement } = await import('react')
return {
StateIndicatorTooltip: ({
label,
children
}: {
label: string | null
children: React.ReactElement
}) =>
label === null
? children
: createElement('span', { 'data-state-indicator-tooltip': label }, children)
}
})
function renderMarkup(state: AgentDotState): string {
return renderToStaticMarkup(React.createElement(AgentStateDot, { state }))
}
function renderDotClassNames(state: AgentDotState): string[] {
const markup = renderMarkup(state)
const dotClassName = markup.match(/<span class="([^"]*rounded-full[^"]*)"/)?.[1]
expect(dotClassName).toBeDefined()
return dotClassName!.split(/\s+/)
}
describe('AgentStateDot', () => {
it('renders working as a yellow spinner', () => {
const markup = renderMarkup('working')
expect(markup).toContain('data-agent-spinner')
})
it('renders monitoring as a static yellow heartbeat glyph', () => {
const markup = renderMarkup('monitoring')
expect(markup).toContain('aria-label="Monitoring background tasks"')
expect(markup).toContain('lucide-activity')
expect(markup).toContain('text-agent-working')
expect(markup).not.toContain('data-agent-spinner')
})
it('renders done as an emerald check icon', () => {
const markup = renderMarkup('done')
// Check completion via the icon class/color rather than Lucide’s SVG path. Sidebar StatusIndicator intentionally uses a dot.
expect(markup).toContain('lucide-circle-check')
expect(markup).toContain('text-emerald-500')
})
it.each(['permission', 'waiting'] satisfies AgentDotState[])(
'renders %s as the shared question glyph',
(state) => {
const markup = renderMarkup(state)
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-agent-question')
expect(markup).not.toContain('text-amber-500')
expect(markup).not.toContain('data-agent-spinner')
}
)
it('renders unverifiable as an amber dashed ring, never the done check or the spinner', () => {
const markup = renderMarkup('unverifiable')
expect(markup).toContain('lucide-circle-dashed')
expect(markup).toContain('text-amber-500')
expect(markup).not.toContain('lucide-circle-check')
expect(markup).not.toContain('data-agent-spinner')
})
it.each(['blocked', 'failed'] satisfies AgentDotState[])(
'renders %s as a red attention dot',
(state) => {
const classNames = renderDotClassNames(state)
expect(classNames).toContain('bg-red-500')
expect(classNames).not.toContain('bg-amber-500')
}
)
it("renders a user's Stop as a muted dot, neither the fault red nor the idle grey", () => {
const classNames = renderDotClassNames('interrupted')
expect(classNames).toContain('bg-muted-foreground')
expect(classNames).not.toContain('bg-red-500')
expect(classNames).not.toContain('bg-neutral-500/40')
})
const ALL_STATES = [
'working',
'monitoring',
'blocked',
'waiting',
'interrupted',
'failed',
'done',
'idle',
'unverifiable',
'unconfirmed',
'permission'
] satisfies AgentDotState[]
it.each(ALL_STATES)('labels %s with the shared hover tooltip', (state) => {
const markup = renderMarkup(state)
expect(markup).toContain(`data-state-indicator-tooltip="${agentStateLabel(state)}"`)
expect(markup).not.toContain(' title=')
})
// Compile-check full tooltip-state coverage so new states cannot be silently skipped.
type UncoveredState = Exclude<AgentDotState, (typeof ALL_STATES)[number]>
const _allStatesAreCovered: UncoveredState extends never ? true : never = true
void _allStatesAreCovered
it('lets a caller override the tooltip', () => {
const markup = renderToStaticMarkup(
React.createElement(AgentStateDot, { state: 'done', title: 'Finished 2m ago' })
)
expect(markup).toContain('data-state-indicator-tooltip="Finished 2m ago"')
expect(markup).not.toContain(' title=')
expect(markup).toContain('aria-label="Done"')
})
it('lets a caller with an existing tooltip suppress the shared tooltip', () => {
const markup = renderToStaticMarkup(
React.createElement(AgentStateDot, { state: 'interrupted', title: null })
)
expect(markup).not.toContain('data-state-indicator-tooltip')
expect(markup).toContain('aria-label="Interrupted"')
expect(renderMarkup('interrupted')).toContain(
`data-state-indicator-tooltip="${agentStateLabel('interrupted')}"`
)
})
})
@@ -1,142 +0,0 @@
// @vitest-environment happy-dom
import React, { act } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { AgentWorkingSpinner } from './AgentWorkingSpinner'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
describe('AgentWorkingSpinner', () => {
it('renders the spinner animation and reduced-motion classes', () => {
const markup = renderToStaticMarkup(React.createElement(AgentWorkingSpinner))
expect(markup).toContain('agent-working-spinner')
expect(markup).toContain('data-agent-spinner')
expect(markup).toContain('border-agent-working')
expect(markup).toContain('border-t-transparent')
expect(markup).toContain('motion-reduce:border-t-agent-working')
})
it('renders when the Web Animations API is unavailable', async () => {
const originalGetAnimations = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'getAnimations'
)
Object.defineProperty(HTMLElement.prototype, 'getAnimations', {
configurable: true,
value: undefined
})
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
try {
await act(async () => {
root.render(<AgentWorkingSpinner />)
})
expect(container.querySelector('[data-agent-spinner]')).not.toBeNull()
} finally {
act(() => root.unmount())
container.remove()
if (originalGetAnimations === undefined) {
Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations')
} else {
Object.defineProperty(HTMLElement.prototype, 'getAnimations', originalGetAnimations)
}
}
})
// Anchor every animationstart, including reveals/restarts. Static negative delays replay stale mount offsets and break phase sync.
it('anchors every animation start to the shared document epoch', async () => {
const animation = {
animationName: 'agent-spinner-rotate',
startTime: 321
} as unknown as Animation
const getAnimations = vi.fn(() => [animation])
const originalGetAnimations = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'getAnimations'
)
Object.defineProperty(HTMLElement.prototype, 'getAnimations', {
configurable: true,
value: getAnimations
})
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const fireAnimationStart = async (): Promise<void> => {
const event = new Event('animationstart', { bubbles: true })
Object.defineProperty(event, 'animationName', { value: 'agent-spinner-rotate' })
await act(async () => {
container.querySelector('[data-agent-spinner]')!.dispatchEvent(event)
})
}
try {
await act(async () => {
root.render(<AgentWorkingSpinner />)
})
// Mount-time animation queries force style recalculation; the first animationstart establishes phase.
expect(getAnimations).not.toHaveBeenCalled()
await fireAnimationStart()
expect(animation.startTime).toBe(0)
animation.startTime = 654
await fireAnimationStart()
expect(animation.startTime).toBe(0)
expect(getAnimations).toHaveBeenCalledTimes(2)
} finally {
act(() => root.unmount())
container.remove()
if (originalGetAnimations === undefined) {
Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations')
} else {
Object.defineProperty(HTMLElement.prototype, 'getAnimations', originalGetAnimations)
}
}
})
it('ignores animation starts from other animations on the same element', async () => {
const animation = {
animationName: 'agent-spinner-rotate',
startTime: 321
} as unknown as Animation
const getAnimations = vi.fn(() => [animation])
const originalGetAnimations = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'getAnimations'
)
Object.defineProperty(HTMLElement.prototype, 'getAnimations', {
configurable: true,
value: getAnimations
})
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
try {
await act(async () => {
root.render(<AgentWorkingSpinner />)
})
const unrelated = new Event('animationstart', { bubbles: true })
Object.defineProperty(unrelated, 'animationName', { value: 'compact-agent-expansion-reveal' })
await act(async () => {
container.querySelector('[data-agent-spinner]')!.dispatchEvent(unrelated)
})
expect(getAnimations).not.toHaveBeenCalled()
expect(animation.startTime).toBe(321)
} finally {
act(() => root.unmount())
container.remove()
if (originalGetAnimations === undefined) {
Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations')
} else {
Object.defineProperty(HTMLElement.prototype, 'getAnimations', originalGetAnimations)
}
}
})
})
@@ -1,27 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { Button } from '@/components/ui/button'
import { ButtonKeyHint } from './ButtonKeyHint'
afterEach(cleanup)
describe('ButtonKeyHint', () => {
it('shows the modifier before the Enter glyph without joining the button name', () => {
render(
<Button>
Create worktree
<ButtonKeyHint modifierLabel="⌘" />
</Button>
)
const button = screen.getByRole('button', { name: 'Create worktree' })
const badge = button.querySelector('[aria-hidden="true"]')
expect(badge?.textContent).toBe('⌘')
expect(badge?.querySelector('svg')).toBeTruthy()
})
it('shows only the Enter glyph when plain Enter submits', () => {
const { container } = render(<ButtonKeyHint />)
expect(container.firstElementChild?.textContent).toBe('')
expect(container.querySelector('svg')).toBeTruthy()
})
})
@@ -20,34 +20,6 @@ import type { JiraIssue } from '../../../shared/jira/jira-types'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
describe('JiraIssueWorkspace reopened issue edits', () => {
it('keeps a newer reopened-issue edit when an earlier save fails', async () => {
const oldSave = deferred<{ ok: false; error: string }>()
runtimeMocks.jiraUpdateIssueForTarget
.mockReturnValueOnce(oldSave.promise)
.mockReturnValue(deferred<{ ok: true }>().promise)
runtimeMocks.jiraGetIssueForTarget.mockImplementation(
async (_settings: unknown, key: string) =>
key === 'ENG-2' ? otherIssue : jiraIssue('Old title')
)
render(<StoreBackedWorkspace initial={jiraIssue('Old title')} />)
await flushPromises()
saveTitle('Old title', 'First edit')
await act(async () => selectIssue(otherIssue))
await flushPromises()
await act(async () => selectIssue(jiraIssue('Old title')))
await flushPromises()
saveTitle('First edit', 'Latest edit')
await flushPromises()
await act(async () => oldSave.resolve({ ok: false, error: 'First save rejected' }))
await flushPromises()
expect(store.patchJiraIssue).toHaveBeenLastCalledWith(
'ENG-1',
expect.objectContaining({ title: 'Latest edit' }),
{ sourceContext: initialSource, siteId: 'site-1', contextKey: initialSourceContextKey }
)
expect(screen.getByDisplayValue('Latest edit')).toBeDefined()
})
it('keeps a newer reopened-issue edit when an earlier refresh returns late', async () => {
const oldSave = deferred<{ ok: true }>()
const oldRefresh = deferred<JiraIssue>()
@@ -4,17 +4,15 @@ import {
deferred,
editState,
flushPromises,
initialSource,
jiraIssue,
otherIssue,
runtimeMocks,
saveTitle,
selectIssue,
selectSource,
store,
StoreBackedWorkspace
} from './__mocks__/jira-issue-workspace-edit-fixture'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { JiraIssue, JiraMutationResult } from '../../../shared/jira/jira-types'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
@@ -93,51 +91,6 @@ describe('JiraIssueWorkspace optimistic edits', () => {
expect(runtimeMocks.jiraIssueCommentsForTarget).toHaveBeenCalledTimes(1)
})
it('reloads when the same issue moves to another source account on the same host', async () => {
render(<StoreBackedWorkspace initial={jiraIssue('Old title')} />)
await flushPromises()
await act(async () => selectSource({ ...initialSource, accountLabel: 'other-account' }))
await flushPromises()
expect(runtimeMocks.jiraGetIssueForTarget).toHaveBeenCalledTimes(2)
expect(runtimeMocks.jiraGetIssueForTarget).toHaveBeenLastCalledWith(
{ kind: 'local' },
'ENG-1',
'site-1'
)
})
it('keeps the new title on screen while the save is in flight', async () => {
const update = deferred<{ ok: true }>()
runtimeMocks.jiraUpdateIssueForTarget.mockReturnValue(update.promise)
render(<StoreBackedWorkspace initial={jiraIssue('Old title')} />)
await flushPromises()
saveTitle('Old title', 'New title')
await flushPromises()
expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(1)
expect(screen.getByDisplayValue('New title')).toBeDefined()
expect(runtimeMocks.jiraGetIssueForTarget).toHaveBeenCalledTimes(1)
editState.serverTitle = 'New title'
await act(async () => update.resolve({ ok: true }))
await flushPromises()
expect(runtimeMocks.jiraGetIssueForTarget).toHaveBeenCalledTimes(2)
expect(screen.getByDisplayValue('New title')).toBeDefined()
})
it('restores the previous title when the save fails', async () => {
runtimeMocks.jiraUpdateIssueForTarget.mockResolvedValue({ ok: false, error: 'Nope' })
render(<StoreBackedWorkspace initial={jiraIssue('Old title')} />)
await flushPromises()
saveTitle('Old title', 'New title')
await flushPromises()
await waitFor(() => expect(screen.getByDisplayValue('Old title')).toBeDefined())
})
it('keeps a cleared priority cleared when an older detail load arrives', async () => {
const initialLoad = deferred<JiraIssue>()
const initial = jiraIssue('Old title', { priority: { id: '1', name: 'High' } })
@@ -154,22 +107,6 @@ describe('JiraIssueWorkspace optimistic edits', () => {
expect(runtimeMocks.jiraIssueCommentsForTarget).toHaveBeenCalledTimes(1)
})
it('keeps fields hydrated during a failed title save', async () => {
const initialLoad = deferred<JiraIssue>()
const update = deferred<{ ok: false; error: string }>()
runtimeMocks.jiraGetIssueForTarget.mockReturnValue(initialLoad.promise)
runtimeMocks.jiraUpdateIssueForTarget.mockReturnValue(update.promise)
render(<StoreBackedWorkspace initial={jiraIssue('Old title')} />)
await flushPromises()
saveTitle('Old title', 'New title')
await act(async () => initialLoad.resolve(jiraIssue('Old title', { labels: ['backend'] })))
await flushPromises()
await act(async () => update.resolve({ ok: false, error: 'Rejected' }))
await flushPromises()
expect(screen.getByDisplayValue('Old title')).toBeDefined()
expect(screen.getByDisplayValue('backend')).toBeDefined()
})
it('allows editing a newly opened issue while an earlier save is still pending', async () => {
const oldSave = deferred<{ ok: true }>()
runtimeMocks.jiraUpdateIssueForTarget
@@ -192,40 +129,6 @@ describe('JiraIssueWorkspace optimistic edits', () => {
expect(screen.getByText('ENG-2')).toBeDefined()
})
it('offers the new status transitions after a status change', async () => {
// Reload transitions after a status change; Jira accepts only current-state transitions.
const inProgress = {
id: '3',
name: 'In Progress',
categoryKey: 'indeterminate',
categoryName: 'In Progress'
}
const done = { id: '4', name: 'Done', categoryKey: 'done', categoryName: 'Done' }
runtimeMocks.jiraListTransitionsForTarget
.mockResolvedValueOnce([{ id: '11', name: 'Start progress', to: inProgress }])
.mockResolvedValue([{ id: '21', name: 'Finish', to: done }])
runtimeMocks.jiraUpdateIssueForTarget.mockResolvedValue({ ok: true })
runtimeMocks.jiraGetIssueForTarget
.mockResolvedValueOnce(jiraIssue('Old title'))
.mockResolvedValue(jiraIssue('Old title', { status: inProgress }))
render(<StoreBackedWorkspace initial={jiraIssue('Old title')} />)
await flushPromises()
fireEvent.click(screen.getByRole('button', { name: 'Change status: To Do' }))
fireEvent.click(await screen.findByRole('button', { name: 'Start progress' }))
await flushPromises()
expect(runtimeMocks.jiraUpdateIssueForTarget).toHaveBeenCalledWith(
{ kind: 'local' },
'ENG-1',
{ transitionId: '11' },
'site-1'
)
fireEvent.click(screen.getByRole('button', { name: 'Change status: In Progress' }))
expect(screen.queryByRole('button', { name: 'Start progress' })).toBeNull()
expect(await screen.findByRole('button', { name: 'Finish' })).toBeDefined()
})
it('still hydrates fields the user did not edit when the initial load lands late', async () => {
const initialLoad = deferred<JiraIssue>()
runtimeMocks.jiraGetIssueForTarget.mockImplementationOnce(() => initialLoad.promise)
@@ -55,49 +55,4 @@ describe('JiraIssueWorkspace queued status metadata', () => {
expect(await screen.findByRole('button', { name: 'Finish' })).toBeDefined()
expect(screen.queryByRole('button', { name: 'Start progress' })).toBeNull()
})
it('reloads transitions when the last queued status save fails after an earlier success', async () => {
const oldSave = deferred<{ ok: true }>()
const newerSave = deferred<{ ok: false; error: string }>()
const inProgress = {
id: '3',
name: 'In Progress',
categoryKey: 'indeterminate',
categoryName: 'In Progress'
}
const done = { id: '4', name: 'Done', categoryKey: 'done', categoryName: 'Done' }
const start = { id: '11', name: 'Start progress', to: inProgress }
const finish = { id: '21', name: 'Finish', to: done }
runtimeMocks.jiraListTransitionsForTarget
.mockResolvedValueOnce([start])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([start, finish])
.mockResolvedValue([finish])
runtimeMocks.jiraUpdateIssueForTarget
.mockReturnValueOnce(oldSave.promise)
.mockReturnValueOnce(newerSave.promise)
runtimeMocks.jiraGetIssueForTarget
.mockResolvedValueOnce(jiraIssue('Old title'))
.mockResolvedValueOnce(otherIssue)
.mockResolvedValueOnce(jiraIssue('Old title'))
.mockResolvedValue(jiraIssue('Old title', { status: inProgress }))
render(<StoreBackedWorkspace initial={jiraIssue('Old title')} />)
await flushPromises()
fireEvent.click(screen.getByRole('button', { name: 'Change status: To Do' }))
fireEvent.click(await screen.findByRole('button', { name: 'Start progress' }))
await act(async () => selectIssue(otherIssue))
await flushPromises()
await act(async () => selectIssue(jiraIssue('Old title')))
await flushPromises()
if (!screen.queryByRole('button', { name: 'Finish' })) {
fireEvent.click(screen.getByRole('button', { name: 'Change status: In Progress' }))
}
fireEvent.click(await screen.findByRole('button', { name: 'Finish' }))
await act(async () => oldSave.resolve({ ok: true }))
await flushPromises()
await act(async () => newerSave.resolve({ ok: false, error: 'Status rejected' }))
await flushPromises()
expect(runtimeMocks.jiraListTransitionsForTarget).toHaveBeenCalledTimes(4)
expect(screen.queryByRole('button', { name: 'Start progress' })).toBeNull()
})
})
@@ -13,7 +13,6 @@ import {
disclosureOf,
nodeBox,
onToggleSpy,
queryChatBox,
seedTree,
treeOutline,
unretryable,
@@ -106,43 +105,6 @@ it('selects every eligible chat in a project, never an unretryable one or anothe
expect(countOf('orca-mobile')).toBe('1 of 1')
})
it('disables a node with nothing it could select', () => {
const candidates = seedTree()
render({
candidates,
initiallySelected: [],
failureFor: (sessionId) => (sessionId === 'in-other' ? unretryable(candidates[3]!) : undefined)
})
expect(nodeBox('other').hasAttribute('disabled')).toBe(true)
expect(countOf('other')).toBeUndefined()
})
it('disables every checkbox while a resume runs', () => {
render({ candidates: seedTree(), busy: true })
const boxes = [...container.querySelectorAll('[role="checkbox"]')]
expect(boxes).toHaveLength(8)
for (const box of boxes) {
expect(box.hasAttribute('disabled')).toBe(true)
}
})
it('selects a whole row by clicking anywhere on it, but its arrow only folds', () => {
render({ candidates: seedTree(), initiallySelected: [] })
act(() => itemLabelBody('child').click())
expect(state(chatBox('in-child'))).toBe('true')
act(() => disclosureOf('other').click())
expect(state(nodeBox('other'))).toBe('false')
expect(queryChatBox('in-other')).toBeNull()
})
function itemLabelBody(name: string): HTMLElement {
return nodeBox(name).closest('label')!.querySelector<HTMLElement>(':scope > :last-child')!
}
it('starts expanded; collapsing hides a node’s rows and keeps their selection', () => {
render({ candidates: seedTree() })
const parent = disclosureOf('parent')
@@ -3,21 +3,15 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import * as worktreeIds from '../../../shared/worktree/id'
import { useAppStore } from '../store'
import { getDefaultSettings } from '../../../shared/constants'
import { getHostContextLabel } from '../../../shared/worktree/host-context-labels'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping'
import {
candidate,
chatBox,
itemOf,
lineage,
nodeBox,
onToggleSpy,
seedTree,
treeOutline,
worktree
} from './native-chat-resume-tree.test-support'
import { ResumeTreeHarness as Harness } from './NativeChatResumeTreeHarness.test-support'
@@ -45,132 +39,6 @@ afterEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
})
const LOCAL_TREE = [
'1:project:orca',
'2:workspace:parent',
'3:chat:in-parent',
'3:workspace:child',
'4:chat:in-child',
'4:chat:also-in-child',
'2:workspace:other',
'3:chat:in-other'
]
it('lists project, workspace and chat levels, a child workspace after its parent’s chats', () => {
render({ candidates: seedTree() })
expect(treeOutline()).toEqual(LOCAL_TREE)
})
it('keeps every checkbox in the left column and indents what follows it 18px a level', () => {
render({ candidates: seedTree() })
for (const item of container.querySelectorAll<HTMLElement>('[role="treeitem"]')) {
const row = item.querySelector('label')!
const cell = (n: number) => row.querySelector<HTMLElement>(`:scope > :nth-child(${n})`)
const disclosure = cell(3)
expect(cell(1)?.querySelector('[role="checkbox"]')).not.toBeNull()
const level = Number(item.getAttribute('aria-level'))
expect(cell(2)?.style.width).toBe(`${(level - 1) * 18}px`)
// Every row keeps the arrow's slot, so icons line up; only a node with children has an arrow.
expect(disclosure?.getAttribute('aria-hidden')).toBe('true')
const arrow = item.querySelector<HTMLElement>('button[aria-expanded]')
expect(arrow !== null).toBe(item.hasAttribute('aria-expanded'))
// Laid over its slot, outside the label, so it never toggles the checkbox.
expect(arrow?.closest('label') ?? null).toBeNull()
if (arrow) {
expect(arrow.style.left).toBe(`${28 + (level - 1) * 18}px`)
}
}
})
it('reads levels apart by weight, with no row dividers or bands', () => {
render({ candidates: seedTree() })
expect(itemOf(nodeBox('orca')).querySelector('.font-semibold')?.textContent).toBe('orca')
expect(itemOf(nodeBox('parent')).querySelector('.font-medium')?.textContent).toBe('parent')
const rows = container.querySelectorAll(
'[role="treeitem"], [role="treeitem"] *:not([role="checkbox"])'
)
for (const element of rows) {
expect([...element.classList].filter((name) => /^(border|bg-)/.test(name))).toEqual([])
}
})
it('shows the branch for a git worktree, muted after its name', () => {
render({ candidates: seedTree() })
const branch = [...itemOf(nodeBox('parent')).querySelectorAll('span')].find(
(span) => span.textContent === 'parent-branch'
)
expect(branch?.classList.contains('text-muted-foreground')).toBe(true)
})
it('starts at projects when every chat is on this machine', () => {
render({ candidates: seedTree() })
expect(container.textContent).not.toContain(getHostContextLabel('local'))
expect(container.querySelector('[aria-label^="Select all chats on"]')).toBeNull()
})
it('heads a single SSH host with its machine node, named and marked SSH', () => {
const remote: ExecutionHostId = 'ssh:build-server'
render({ candidates: seedTree({ parent: remote, child: remote }) })
const machine = getHostContextLabel(remote)
expect(treeOutline()).toEqual([
`1:machine:${machine}`,
...LOCAL_TREE.map((row) => row.replace(/^(\d)/, (level) => String(Number(level) + 1)))
])
expect(itemOf(nodeBox(machine)).textContent).toContain('SSH')
// The machine node names the host, so no workspace repeats it.
expect(itemOf(nodeBox('parent')).textContent).not.toContain(machine)
})
it('heads a runtime host with its machine node and no SSH chip', () => {
const runtime: ExecutionHostId = 'runtime:env-1'
render({ candidates: seedTree({ parent: runtime, child: runtime }) })
const machine = getHostContextLabel(runtime)
expect(treeOutline()[0]).toBe(`1:machine:${machine}`)
expect(itemOf(nodeBox(machine)).textContent).not.toContain('SSH')
})
it('names an SSH machine by its saved name, not its target id', () => {
const targetId = 'ssh-1728291234567-abc12d'
const remote: ExecutionHostId = `ssh:${targetId}`
const candidates = seedTree({ parent: remote, child: remote })
useAppStore.setState({ sshTargetLabels: new Map([[targetId, 'devbox']]) })
render({ candidates })
expect(itemOf(nodeBox('devbox')).getAttribute('aria-level')).toBe('1')
expect(container.textContent).not.toContain(targetId)
})
it('gives each machine its own branch when chats span hosts', () => {
const remote: ExecutionHostId = 'ssh:build-server'
const candidates = seedTree({ parent: 'local', child: remote })
render({ candidates })
const local = getHostContextLabel('local')
const machine = getHostContextLabel(remote)
// The child is on another host, so it does not nest under the local parent.
expect(treeOutline()).toEqual([
`1:machine:${machine}`,
'2:project:orca',
'3:workspace:child',
'4:chat:in-child',
'4:chat:also-in-child',
`1:machine:${local}`,
'2:project:orca',
'3:workspace:parent',
'4:chat:in-parent',
'3:workspace:other',
'4:chat:in-other'
])
expect(itemOf(nodeBox(local)).textContent).not.toContain('SSH')
})
it('lists every offered chat exactly once, even with one workspace id on two hosts', () => {
const remote: ExecutionHostId = 'ssh:build-server'
const candidates = seedTree()
@@ -190,139 +58,6 @@ it('lists every offered chat exactly once, even with one workspace id on two hos
expect(container.querySelectorAll('[aria-label="Select all chats in parent"]')).toHaveLength(2)
})
it('names a workspace the store does not know by its id, with the kind the host recorded', () => {
const unknown: ResumeCandidate = {
...candidate('lost', worktree('gone')),
workspaceId: 'folder:missing-folder',
workspaceKind: 'folder'
}
render({ candidates: [unknown] })
const row = itemOf(nodeBox('folder:missing-folder'))
expect(row.querySelector('svg.lucide-folder')).not.toBeNull()
expect(chatBox('lost').getAttribute('aria-label')).toContain('in folder:missing-folder')
})
it('names a git worktree the store does not know by its folder, not its repo-qualified id', () => {
render({ candidates: [candidate('lost', worktree('blobfish-2'))] })
expect(nodeBox('blobfish-2')).not.toBeNull()
expect(chatBox('lost').getAttribute('aria-label')).toContain('in blobfish-2')
expect(container.textContent).not.toContain('repo-1::')
})
it('disambiguates only colliding unknown folders with linear display work on render', () => {
const known = worktree('main', { displayName: 'Configured', hostId: 'local' })
useAppStore.setState({ worktreesByRepo: { 'repo-1': [known] } })
const controls = [
candidate('known', known),
candidate('unique', worktree('blobfish-2')),
candidate('opaque', worktree('opaque', { id: 'folder:missing-folder' }))
]
const basename = vi.spyOn(worktreeIds, 'getWorktreePathBasenameFromId')
let nameReads = 0
const mapSet = Map.prototype.set
vi.spyOn(Map.prototype, 'set').mockImplementation(function (
this: Map<unknown, unknown>,
key,
value
) {
if (typeof value?.parentPath === 'string' && value.name === 'main') {
const name = value.name
Object.defineProperty(value, 'name', {
get: () => {
nameReads++
return name
}
})
}
return mapSet.call(this, key, value)
})
const operations: number[] = []
let unknown: ResumeCandidate[] = []
for (const size of [10, 100]) {
unknown = Array.from({ length: size }, (_, index) =>
candidate(
`lost-${index}`,
worktree('main', {
id: `repo-1::/Users/test/projects/organization/orca/workspaces/client-${index}/main`
})
)
)
basename.mockClear()
nameReads = 0
render({ candidates: [...controls, ...unknown] })
const parses = basename.mock.calls.filter(([id]) => id.startsWith('repo-1::/Users/')).length
const work = parses + nameReads
expect(Math.min(parses, nameReads)).toBeGreaterThanOrEqual(size)
expect(work).toBeLessThanOrEqual(6 * size)
operations.push(work)
for (const index of [0, 1]) {
const parent = `/Users/test/projects/organization/orca/workspaces/client-${index}`
const detail = container.querySelector(`[title="${parent}"]`)
expect(detail?.textContent).toBe(parent)
expect(detail?.lastElementChild?.textContent).toBe(`/workspaces/client-${index}`)
const label = `main (${parent})`
const row = itemOf(nodeBox(label))
expect(row.querySelector('.font-medium')?.textContent).toBe('main')
expect(row.querySelector('button[aria-expanded]')?.getAttribute('aria-label')).toBe(
`Collapse ${label}`
)
expect(chatBox(`lost-${index}`).getAttribute('aria-label')).toContain(`in ${label}`)
}
expect(itemOf(nodeBox('Configured')).textContent).toContain('main-branch')
expect(itemOf(nodeBox('blobfish-2')).querySelector('[title]')).toBeNull()
expect(nodeBox('folder:missing-folder')).not.toBeNull()
}
expect(operations[1]).toBeLessThanOrEqual(12 * operations[0]!)
const collisionBoxes = container.querySelectorAll<HTMLElement>(
'[role="checkbox"][aria-label^="Select all chats in main ("]'
)
act(() => collisionBoxes[1]!.click())
expect(onToggleSpy).toHaveBeenCalledExactlyOnceWith('lost-1', false)
render({ candidates: unknown.slice(0, 2) })
const hydrated = { ...known, id: unknown[0]!.workspaceId, displayName: 'Resolved' }
act(() => useAppStore.setState({ worktreesByRepo: { 'repo-1': [known, hydrated] } }))
expect(nodeBox('Resolved')).not.toBeNull()
expect(itemOf(nodeBox('main')).querySelector('[title]')).toBeNull()
expect(container.querySelector('[aria-label^="Select all chats in main ("]')).toBeNull()
expect(
itemOf(nodeBox('main')).querySelector('button[aria-expanded]')?.getAttribute('aria-label')
).toBe('Collapse main')
expect(chatBox('lost-1').getAttribute('aria-label')).toBe(
'Resume Codex chat "Prompt lost-1" in main'
)
})
// Its project is unknown too: a nameless "Select all chats in " checkbox would help no one.
it('gives workspaces the store cannot place no project node', () => {
const unknown: ResumeCandidate = {
...candidate('lost', worktree('gone')),
workspaceId: 'folder:missing-folder',
workspaceKind: 'folder'
}
render({ candidates: [...seedTree(), unknown] })
expect(treeOutline()).toEqual([...LOCAL_TREE, '1:workspace:folder:missing-folder', '2:chat:lost'])
expect(container.querySelector('[aria-label="Select all chats in "]')).toBeNull()
})
// Nest only same-host workspaces; unknown host matches only unknown host, as in the sidebar.
it.each([
['both local', 'local', 'local', true],
['neither with a host id', undefined, undefined, true],
['parent without a host id, child local', undefined, 'local', false],
['parent local, child without a host id', 'local', undefined, false]
] as const)(
'%s: nests the child exactly as the sidebar does',
(_, parentHost, childHost, nests) => {
render({ candidates: seedTree({ parent: parentHost, child: childHost }) })
expect(treeOutline()).toContain(nests ? '3:workspace:child' : '2:workspace:child')
expect(container.querySelectorAll('[role="checkbox"][aria-label^="Resume"]')).toHaveLength(4)
}
)
it('lists every chat when workspace lineage loops', () => {
const a = worktree('a')
const b = worktree('b')
@@ -1,349 +0,0 @@
import { describe, expect, it } from 'vitest'
import type {
AgentJournalRenderItem,
AgentJournalSubmission,
AgentJournalTurnLifecycle
} from '../journal/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../journal/agent-session-journal-item-key'
import {
DISPATCH_REJECTED_CANCELLED,
DISPATCH_REJECTED_HOST_RESTARTED,
DISPATCH_REJECTED_NOT_DELIVERED,
DISPATCH_REJECTED_PROVIDER_CLOSED
} from './structured-agent-session-dispatch-rejection'
import {
hasStructuredAgentSessionRequest,
latestStructuredAgentSessionRequest
} from './structured-agent-session-latest-request'
import { projectStructuredAgentSessionStatusSummary } from '../view/structured-agent-session-projection'
import { latestNativeChatTurnSinceRequest } from '../../native-chat/native-chat-orca-stop-cut'
import { selectStructuredAgentTurnTimings } from '../view/structured-agent-session-turn-timing'
const START_FAILURE = 'Claude is not signed in.'
it('keeps unresolved human input as a latest-request uncertainty boundary without a lifecycle', () => {
const prior = turn('prior', 1, {
state: 'interrupted',
startedAt: 10,
completedAt: 20,
outcome: 'cancellation'
})
const unknown: AgentJournalRenderItem = {
...userEntry('outside', 2),
turnScope: { kind: 'unknown' }
}
const items = [prior, unknown]
expect(latestStructuredAgentSessionRequest([prior], [])?.outcome).toBe('cancellation')
expect(latestStructuredAgentSessionRequest(items, [])).toBeNull()
expect(latestNativeChatTurnSinceRequest(items, [])).toBeNull()
expect(hasStructuredAgentSessionRequest([unknown], [])).toBe(true)
expect(selectStructuredAgentTurnTimings(items)).toEqual(selectStructuredAgentTurnTimings([prior]))
const next = turn('next', 3, { state: 'running', startedAt: 30 })
expect(latestStructuredAgentSessionRequest([...items, next], [])).toMatchObject({
id: 'next',
turnState: 'running',
outcome: null
})
})
/** `intoTurn` names the turn the message's handover delivered it into — a steer. */
function userEntry(
clientMessageId: string,
sequence: number,
intoTurn?: string
): AgentJournalRenderItem {
return {
itemId: agentJournalSubmissionKey(clientMessageId),
revision: 0,
sequence,
observedAt: sequence,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] },
turnScope: intoTurn
? { kind: 'turn', turnItemId: `codex:turn:${intoTurn}` }
: { kind: 'thread' }
}
}
function turn(
turnId: string,
sequence: number,
fields: Omit<AgentJournalTurnLifecycle, 'turnId'>
): AgentJournalRenderItem {
return {
itemId: `codex:turn:${turnId}`,
revision: 1,
sequence,
observedAt: sequence,
body: { kind: 'turn', turnId, ...fields }
}
}
function sent(
clientMessageId: string,
fields: Partial<AgentJournalSubmission> & Pick<AgentJournalSubmission, 'dispatchState'>
): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
providerItemId: null,
reason: null,
submittedAt: 10,
resolvedAt: 20,
handoverRecorded: true,
...fields
}
}
const rejected = (clientMessageId: string, reason: string, handedOverAt?: number) =>
sent(clientMessageId, {
dispatchState: 'rejected',
reason,
...(handedOverAt !== undefined ? { handedOverAt } : {})
})
type Row = {
name: string
items: AgentJournalRenderItem[]
submissions: AgentJournalSubmission[]
fence?: number
outcome: 'success' | 'failure' | 'cancellation' | null
listed: boolean
}
const ROWS: Row[] = [
{
name: 'a send the agent start refused',
items: [userEntry('m1', 1)],
submissions: [rejected('m1', START_FAILURE)],
outcome: 'failure',
listed: true
},
{
name: 'a send the user withdrew (no verdict)',
items: [userEntry('m1', 1)],
submissions: [rejected('m1', DISPATCH_REJECTED_CANCELLED)],
outcome: null,
listed: false
},
{
name: 'a send still queued when the host restarted (no verdict)',
items: [userEntry('m1', 1)],
submissions: [rejected('m1', DISPATCH_REJECTED_HOST_RESTARTED)],
outcome: null,
listed: false
},
{
name: 'a send still queued when the provider closed (no verdict)',
items: [userEntry('m1', 1)],
submissions: [rejected('m1', DISPATCH_REJECTED_PROVIDER_CLOSED)],
outcome: null,
listed: false
},
{
name: 'a send crash recovery found the provider never received (no verdict)',
items: [userEntry('m1', 1)],
submissions: [
sent('m1', {
dispatchState: 'rejected',
reason: DISPATCH_REJECTED_NOT_DELIVERED,
recovered: true
})
],
outcome: null,
listed: false
},
{
name: 'a turn that succeeded, then a send crash recovery found the provider never received',
items: [
userEntry('m1', 1),
turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 15 }),
userEntry('m2', 3)
],
submissions: [
sent('m1', { dispatchState: 'accepted' }),
sent('m2', {
dispatchState: 'rejected',
reason: DISPATCH_REJECTED_NOT_DELIVERED,
recovered: true
})
],
outcome: 'success',
listed: true
},
{
name: 'a send crash recovery left unknown',
items: [userEntry('m1', 1)],
submissions: [sent('m1', { dispatchState: 'unknown', recovered: true })],
outcome: null,
listed: false
},
{
name: 'a send pending at an older fence',
items: [userEntry('m1', 1)],
submissions: [sent('m1', { dispatchState: 'pending', handedOverAt: 11, resolvedAt: null })],
fence: 2,
outcome: null,
listed: false
},
{
name: 'a turn that succeeded, then a send the start refused',
items: [
userEntry('m1', 1),
turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 15 }),
userEntry('m2', 3)
],
submissions: [sent('m1', { dispatchState: 'accepted' }), rejected('m2', START_FAILURE)],
outcome: 'failure',
listed: true
},
{
name: 'a refused send, then a turn that succeeded',
items: [
userEntry('m1', 1),
userEntry('m2', 2),
turn('t2', 3, { state: 'completed', outcome: 'success', completedAt: 40 })
],
submissions: [rejected('m1', START_FAILURE), sent('m2', { dispatchState: 'accepted' })],
outcome: 'success',
listed: true
},
{
name: 'a steer the provider refused inside a turn that then succeeded',
items: [
userEntry('m1', 1),
turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 50 }),
userEntry('steer', 3)
],
submissions: [
sent('m1', { dispatchState: 'accepted' }),
sent('steer', {
dispatchState: 'rejected',
reason: 'no active turn to steer',
answeredInTurn: { turnItemId: 'codex:turn:t1', via: 'steer' }
})
],
outcome: 'success',
listed: true
},
{
name: 'a user message the provider journaled itself (history, an older host)',
items: [
{
itemId: 'codex:thread-1:turn-1:0',
revision: 0,
sequence: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'from history' }] }
}
],
submissions: [],
outcome: null,
listed: true
},
{
name: 'a goal and nothing else yet',
items: [
{
itemId: 'codex:goal-entry',
revision: 0,
sequence: 1,
observedAt: 1,
body: {
kind: 'message',
role: 'user',
sentAs: 'goal',
blocks: [{ type: 'text', text: 'ship it' }]
}
}
],
submissions: [],
outcome: null,
listed: true
}
]
describe('the latest request and its verdict', () => {
it.each(ROWS)('$name', ({ items, submissions, fence, outcome, listed }) => {
expect(latestStructuredAgentSessionRequest(items, submissions)?.outcome ?? null).toBe(outcome)
const summary = projectStructuredAgentSessionStatusSummary(items, submissions, fence)
expect(summary.status !== null).toBe(listed)
if (listed && summary.status === 'idle') {
expect(summary.turnOutcome ?? null).toBe(outcome)
}
})
it('dates an idle session by the refusal it reports, not by the older turn', () => {
const items = [
userEntry('m1', 1),
turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 15 }),
userEntry('m2', 3)
]
const submissions = [sent('m1', { dispatchState: 'accepted' }), rejected('m2', START_FAILURE)]
expect(projectStructuredAgentSessionStatusSummary(items, submissions)).toMatchObject({
status: 'idle',
turnOutcome: 'failure',
statusStartedAt: 20
})
})
})
describe('the sidebar verdict agrees with the rejection classifier', () => {
it('replaces the prior success with the model rejection verdict', () => {
const items = [
turn('prior', 1, { state: 'completed', outcome: 'success', completedAt: 10 }),
userEntry('m1', 2)
]
const submissions = [
sent('m1', {
dispatchState: 'rejected',
reason: "Grok couldn't use retired. Pick another model and send again.",
rejection: { kind: 'modelUnavailable' }
})
]
expect(latestStructuredAgentSessionRequest(items, submissions)).toMatchObject({
kind: 'refused-send',
outcome: 'failure',
id: agentJournalSubmissionKey('m1')
})
expect(hasStructuredAgentSessionRequest([items[1]!], submissions)).toBe(true)
expect(projectStructuredAgentSessionStatusSummary(items, submissions)).toMatchObject({
status: 'idle',
turnOutcome: 'failure'
})
})
})
describe('the assistant line plain-text surfaces show', () => {
function message(
itemId: string,
sequence: number,
role: 'user' | 'assistant',
text: string
): AgentJournalRenderItem {
return {
itemId,
revision: 0,
sequence,
observedAt: sequence,
body: { kind: 'message', role, blocks: [{ type: 'text', text }] }
}
}
it('keeps a visual line out of the preview every status reader shows', () => {
const items = [
message('ask', 1, 'user', 'chart it'),
message(
'said',
2,
'assistant',
'p95 is highest in ap-south.\n\n::orca-visual{file="latency.html" title="p95"}'
),
message('only-visual', 3, 'assistant', '::orca-visual{file="table.html"}')
]
// A reply that is nothing but a visual leaves the turn's earlier prose as the preview.
expect(projectStructuredAgentSessionStatusSummary(items).lastAssistantMessage).toBe(
'p95 is highest in ap-south.'
)
})
})
@@ -6,35 +6,7 @@ import { isOpenCodeRunCommand } from './opencode-headless-command'
const matches = (commandLine: string): boolean =>
isOpenCodeRunCommand(tokenizeCommandLine(commandLine))
describe('isOpenCodeRunCommand', () => {
it('matches the run subcommand, after global options too', () => {
expect(matches('opencode run fix the bug')).toBe(true)
expect(matches('/opt/homebrew/bin/opencode2 run --model x/y hi')).toBe(true)
expect(matches('opencode --print-logs run hi')).toBe(true)
expect(matches('opencode --log-level DEBUG run hi')).toBe(true)
expect(matches('opencode --log-level=DEBUG run hi')).toBe(true)
})
it('does not match the TUI or any other subcommand', () => {
expect(matches('opencode')).toBe(false)
expect(matches('opencode .')).toBe(false)
expect(matches('opencode2 --standalone')).toBe(false)
expect(matches('opencode serve --port 4096')).toBe(false)
expect(matches('opencode attach http://127.0.0.1:4096')).toBe(false)
expect(matches('opencode mini')).toBe(false)
expect(matches('opencode --log-level run')).toBe(false)
})
})
describe('wrapped OpenCode run command position', () => {
it.each([
'CUSTOM_CONFIG=private opencode --log-level debug run --standalone',
'env CUSTOM_CONFIG=private opencode run --standalone',
'CUSTOM_CONFIG=private /usr/bin/env -- EXTRA_CONFIG=kept opencode run --standalone'
])('recognizes only the executable behind supported POSIX prefixes: %s', (command) => {
expect(matches(command)).toBe(true)
})
it('recognizes a PowerShell call operator before a quoted executable', () => {
const parsed = tokenizeStartupCommand(
'& "C:\\Program Files\\opencode\\opencode.exe" run --standalone',
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
MISSING_AUTOMATION_PROJECT_ERROR,
applyAutomationExecutionTarget,
deriveAutomationExecutionTargetForCreate,
deriveAutomationExecutionTargetForUpdate,
@@ -21,34 +20,6 @@ const storedPinnedLocal: AutomationStoredExecutionTarget = {
}
describe('deriveAutomationExecutionTargetForCreate', () => {
it('fails closed when the project does not resolve', () => {
expect(() =>
deriveAutomationExecutionTargetForCreate({ repo: undefined, sshTargetGeneration: undefined })
).toThrow(MISSING_AUTOMATION_PROJECT_ERROR)
})
it('stores the current registration generation for an SSH project', () => {
expect(
deriveAutomationExecutionTargetForCreate({
repo: { connectionId: 'ssh-1' },
sshTargetGeneration: 4
})
).toEqual({
executionTargetType: 'ssh',
executionTargetId: 'ssh-1',
executionTargetGeneration: 4
})
})
it('omits the generation for a local project', () => {
expect(
deriveAutomationExecutionTargetForCreate({
repo: { connectionId: null },
sshTargetGeneration: 9
})
).toEqual({ executionTargetType: 'local', executionTargetId: 'local' })
})
it('captures the pinned registration for a local project whose workspace runs on SSH', () => {
expect(
deriveAutomationExecutionTargetForCreate({
@@ -65,17 +36,6 @@ describe('deriveAutomationExecutionTargetForCreate', () => {
})
describe('deriveAutomationExecutionTargetForUpdate', () => {
it('preserves the stored SSH selector when the owning project was deleted', () => {
expect(
deriveAutomationExecutionTargetForUpdate({
current: storedSsh,
repo: undefined,
selectorMoveRequested: false,
sshTargetGeneration: undefined
})
).toEqual(storedSsh)
})
// The repo resolving is not consent: this is the pause-an-orphan path.
it('preserves the stored selector when the resolved project points somewhere else', () => {
expect(
@@ -88,43 +48,6 @@ describe('deriveAutomationExecutionTargetForUpdate', () => {
).toEqual(storedSsh)
})
it('keeps a captured generation the current target no longer has', () => {
expect(
deriveAutomationExecutionTargetForUpdate({
current: storedSsh,
repo: { connectionId: 'ssh-1' },
selectorMoveRequested: false,
sshTargetGeneration: undefined
})
).toEqual(storedSsh)
})
it('throws when a requested move has no resolvable project', () => {
expect(() =>
deriveAutomationExecutionTargetForUpdate({
current: storedSsh,
repo: undefined,
selectorMoveRequested: true,
sshTargetGeneration: undefined
})
).toThrow(MISSING_AUTOMATION_PROJECT_ERROR)
})
it('re-derives from a resolved project once a move is requested', () => {
expect(
deriveAutomationExecutionTargetForUpdate({
current: storedSsh,
repo: { connectionId: 'ssh-2' },
selectorMoveRequested: true,
sshTargetGeneration: 11
})
).toEqual({
executionTargetType: 'ssh',
executionTargetId: 'ssh-2',
executionTargetGeneration: 11
})
})
it('captures the new pin when the update re-points the record at another workspace', () => {
expect(
deriveAutomationExecutionTargetForUpdate({
@@ -154,35 +77,9 @@ describe('deriveAutomationExecutionTargetForUpdate', () => {
})
).toEqual({ executionTargetType: 'local', executionTargetId: 'local' })
})
it('keeps the capture while the pin is unchanged', () => {
expect(
deriveAutomationExecutionTargetForUpdate({
current: storedPinnedLocal,
repo: { connectionId: null },
selectorMoveRequested: false,
sshTargetGeneration: undefined,
workspaceSshPin: { targetId: 'ssh-1', generation: 4 },
workspaceSshPinMoved: false
})
).toEqual(storedPinnedLocal)
})
})
describe('applyAutomationExecutionTarget', () => {
it('clears a stale generation when the record moves off SSH', () => {
const next = applyAutomationExecutionTarget(
{ ...storedSsh, name: 'keep me' },
{ executionTargetType: 'local', executionTargetId: 'local' }
)
expect(next).toEqual({
executionTargetType: 'local',
executionTargetId: 'local',
name: 'keep me'
})
expect(Object.hasOwn(next, 'executionTargetGeneration')).toBe(false)
})
// Losing it here is what made a same-id re-registration read as the host the user chose.
it('keeps the capture when the destination is still pinned to the same SSH target', () => {
const next = applyAutomationExecutionTarget(
-84
View File
@@ -1,84 +0,0 @@
import { describe, expect, it } from 'vitest'
import { hostStableKey } from './automation-owner-key'
import type { StableAutomationCatalogRef } from './automation-owner-ref'
import {
ALL_AUTOMATION_HOSTS_FILTER,
automationHostFilterStableKey,
automationHostFiltersEqual,
parsePersistedAutomationHostFilter,
toPersistedAutomationHostFilter,
type AutomationHostFilter
} from './automation-host-filter'
const sshHost: StableAutomationCatalogRef = {
authority: { kind: 'runtime', environmentId: 'env-1' },
selector: { kind: 'ssh', targetId: 'ssh:a' }
}
const sshFilter: AutomationHostFilter = { kind: 'host', host: sshHost }
describe('persisted automation host filter', () => {
it('persists only the stable form as a canonical key', () => {
expect(toPersistedAutomationHostFilter(sshFilter)).toEqual({
kind: 'host',
hostKey: hostStableKey(sshHost)
})
expect(toPersistedAutomationHostFilter(ALL_AUTOMATION_HOSTS_FILTER)).toEqual({ kind: 'all' })
})
it('round-trips every stable selector kind', () => {
const filters: AutomationHostFilter[] = [
{ kind: 'host', host: { authority: { kind: 'desktop' }, selector: { kind: 'self' } } },
{ kind: 'host', host: { authority: { kind: 'desktop' }, selector: { kind: 'orphan' } } },
{
kind: 'host',
host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'a:b' } }
},
sshFilter
]
for (const filter of filters) {
expect(parsePersistedAutomationHostFilter(toPersistedAutomationHostFilter(filter))).toEqual(
filter
)
}
})
it.each([
undefined,
null,
'host:desktop:self',
42,
{},
{ kind: 'host' },
{ kind: 'host', hostKey: 5 },
{ kind: 'host', hostKey: '' },
{ kind: 'host', hostKey: 'desktop:self' },
{ kind: 'host', hostKey: 'host:runtime' },
{ kind: 'host', hostKey: 'host:desktop:bogus' },
{ kind: 'host', hostKey: 'host:desktop:ssh:%zz' },
{ kind: 'nonsense' }
])('falls back to All hosts for malformed value %j without throwing', (value) => {
expect(parsePersistedAutomationHostFilter(value)).toEqual({ kind: 'all' })
})
it('exposes the stable key and compares by it', () => {
expect(automationHostFilterStableKey(ALL_AUTOMATION_HOSTS_FILTER)).toBeNull()
expect(automationHostFilterStableKey(sshFilter)).toBe(hostStableKey(sshHost))
expect(automationHostFiltersEqual(sshFilter, { kind: 'host', host: { ...sshHost } })).toBe(true)
expect(automationHostFiltersEqual(sshFilter, ALL_AUTOMATION_HOSTS_FILTER)).toBe(false)
})
it('does not collide equal target ids across authorities', () => {
const desktop: AutomationHostFilter = {
kind: 'host',
host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'shared' } }
}
const runtime: AutomationHostFilter = {
kind: 'host',
host: {
authority: { kind: 'runtime', environmentId: 'env-1' },
selector: { kind: 'ssh', targetId: 'shared' }
}
}
expect(automationHostFiltersEqual(desktop, runtime)).toBe(false)
})
})
@@ -1,111 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Automation } from './automations-types'
import { AUTOMATION_ORPHAN_ISSUES } from './automation-list-scope'
import { partitionLegacyAutomationList } from './automation-legacy-list-partition'
function automation(overrides: Partial<Automation> = {}): Automation {
return {
id: 'a1',
name: 'Nightly',
prompt: 'go',
precheck: null,
agentId: 'claude',
projectId: 'repo-local',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: 0,
enabled: true,
nextRunAt: 0,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 0,
updatedAt: 0,
...overrides
} as Automation
}
const context = {
repoConnectionId: (repoId: string) =>
repoId === 'repo-local' ? null : repoId === 'repo-ssh' ? 'ssh-1' : undefined,
projectsAuthoritative: true
}
/** What a client holds about a foreign authority: a mirror that may simply not be loaded. */
const mirroredContext = { ...context, projectsAuthoritative: false }
describe('partitionLegacyAutomationList', () => {
it('sends a local record with a resolvable local project to self', () => {
const [row] = partitionLegacyAutomationList([automation()], context).rows
expect(row?.selector).toEqual({ kind: 'self' })
})
it('sends a valid SSH record to its target with no generation attached', () => {
const [row] = partitionLegacyAutomationList(
[automation({ executionTargetType: 'ssh', executionTargetId: 'ssh-1' })],
context
).rows
expect(row?.selector).toEqual({ kind: 'ssh', targetId: 'ssh-1' })
})
it('orphans unknown types, missing SSH ids, and unresolvable projects', () => {
const partition = partitionLegacyAutomationList(
[
automation({ id: 'x', executionTargetType: 'wat' as Automation['executionTargetType'] }),
automation({ id: 'y', executionTargetType: 'ssh', executionTargetId: ' ' }),
automation({ id: 'z', projectId: 'repo-gone' })
],
context
)
expect(partition.rows.map((row) => row.selector.kind)).toEqual(['orphan', 'orphan', 'orphan'])
expect(partition.rows[2]?.selector).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.projectMissing
})
expect(partition.orphanCount).toBe(3)
})
it('orphans a record scheduled against a runtime instead of moving it', () => {
const partition = partitionLegacyAutomationList(
[automation({ schedulerOwner: 'remote_host_service' })],
context
)
expect(partition.rows[0]?.selector).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere
})
})
it('reports an unresolved project as unverified when the table is only a mirror', () => {
const partition = partitionLegacyAutomationList(
[automation({ projectId: 'repo-gone' })],
mirroredContext
)
expect(partition.rows[0]?.selector).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.projectUnverified
})
})
it('still requires positive evidence for self against a mirrored table', () => {
const [row] = partitionLegacyAutomationList([automation()], mirroredContext).rows
expect(row?.selector).toEqual({ kind: 'self' })
})
it('does not read a bare local value as self when the project is hosted over SSH', () => {
const partition = partitionLegacyAutomationList(
[automation({ projectId: 'repo-ssh' })],
context
)
expect(partition.rows[0]?.selector).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.malformed
})
})
})
@@ -17,23 +17,6 @@ function response(overrides: Record<string, unknown> = {}) {
}
describe('validateAutomationListResponse', () => {
it('accepts a capable response and keeps the reported orphan count', () => {
const result = validateAutomationListResponse(response(), SELF)
expect(result).toMatchObject({ ok: true, invalidRows: 0 })
expect(result.ok && result.result.orphanCount).toBe(3)
expect(result.ok && result.result.automations.map((entry) => entry.id)).toEqual(['a1', 'a2'])
})
// Preserve unreported versus authoritatively none; invented zeroes settle orphan buckets nobody counted.
it('leaves an omitted orphan count unreported instead of committing zero', () => {
const raw = response()
delete (raw as { orphanCount?: number }).orphanCount
const result = validateAutomationListResponse(raw, SELF)
expect(result).toMatchObject({ ok: true, invalidRows: 0 })
expect(result.ok && result.result.orphanCount).toBeUndefined()
expect(result.ok && 'orphanCount' in result.result).toBe(false)
})
it('reports a legacy-shaped payload as unsupported host scope', () => {
const result = validateAutomationListResponse({ automations: [{ id: 'a1' }] }, SELF)
expect(result).toMatchObject({ ok: false, error: { code: 'unsupported_host_scope' } })
@@ -130,16 +113,4 @@ describe('validateAutomationListResponse', () => {
expect(result.ok && result.result.automations).toEqual([])
expect(result.ok && result.invalidRows).toBe(4)
})
it('keeps a row whose usage summary is unusable but blanks the summary', () => {
const result = validateAutomationListResponse(
response({
automations: [{ id: 'a1' }],
items: [{ automationId: 'a1', selector: { kind: 'self' }, usageSummary: { knownRuns: 1 } }]
}),
SELF
)
expect(result.ok && result.result.items[0]?.usageSummary).toBeNull()
expect(result.ok && result.invalidRows).toBe(0)
})
})
-192
View File
@@ -2,11 +2,8 @@ import { describe, expect, it } from 'vitest'
import type { Automation } from './automations-types'
import {
AUTOMATION_ORPHAN_ISSUES,
automationChangePublications,
automationSelectorMatchesScope,
projectAutomationList,
projectAutomationSelector,
toAutomationChangeSelector,
type AutomationProjectionContext
} from './automation-list-scope'
function automation(overrides: Partial<Automation> = {}): Automation {
@@ -49,38 +46,6 @@ function context(
}
describe('projectAutomationSelector', () => {
it('qualifies a local record whose project resolves without a connection as self', () => {
expect(projectAutomationSelector(automation(), context())).toEqual({ kind: 'self' })
})
it('refuses to read a bare local value as self when the project is gone', () => {
expect(projectAutomationSelector(automation({ projectId: 'missing' }), context())).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.projectMissing
})
})
it('treats a local record whose project points at an SSH connection as malformed', () => {
expect(projectAutomationSelector(automation({ projectId: 'repo-ssh' }), context())).toEqual({
kind: 'orphan',
issue: AUTOMATION_ORPHAN_ISSUES.malformed
})
})
it('pins an SSH record to its registration generation', () => {
const record = automation({
executionTargetType: 'ssh',
executionTargetId: 'ssh-1',
executionTargetGeneration: 7,
projectId: 'repo-ssh'
})
expect(projectAutomationSelector(record, context())).toEqual({
kind: 'ssh',
targetId: 'ssh-1',
targetGeneration: 7
})
})
it('orphans an SSH record whose target was removed and re-added', () => {
const record = automation({
executionTargetType: 'ssh',
@@ -141,147 +106,8 @@ describe('automationSelectorMatchesScope', () => {
})
})
describe('projectAutomationList', () => {
const records = [
automation({ id: 'local-1' }),
automation({
id: 'ssh-a',
executionTargetType: 'ssh',
executionTargetId: 'ssh-1',
executionTargetGeneration: 7,
projectId: 'repo-ssh'
}),
automation({ id: 'orphan-1', executionTargetType: 'ssh', executionTargetId: 'ssh-gone' })
]
it('returns every record with one item each when no scope is requested', () => {
const result = projectAutomationList(records, context())
expect(result.automations).toHaveLength(3)
expect(result.items.map((item) => item.automationId)).toEqual(['local-1', 'ssh-a', 'orphan-1'])
expect(result.orphanCount).toBe(1)
})
it('narrows to one host while still reporting the authority orphan count', () => {
const result = projectAutomationList(records, context(), { kind: 'self' })
expect(result.automations.map((entry) => entry.id)).toEqual(['local-1'])
expect(result.orphanCount).toBe(1)
})
it('attaches bounded usage summaries when the authority supplies them', () => {
const result = projectAutomationList(records, {
...context(),
usageSummary: (id) =>
id === 'local-1'
? {
knownRuns: 2,
unavailableRuns: 0,
inputTokens: 1,
outputTokens: 2,
cacheTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 3,
estimatedCostUsd: null
}
: null
})
expect(result.items[0]?.usageSummary?.knownRuns).toBe(2)
expect(result.items[1]?.usageSummary).toBeNull()
})
it('projects and scopes in one collection traversal', () => {
const collectionMethodReads: string[] = []
const repoLookups: string[] = []
const targetLookups: string[] = []
const source = new Proxy(records, {
get(target, property, receiver) {
if (property === 'map' || property === 'filter') {
collectionMethodReads.push(String(property))
}
// oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, property, receiver)
}
})
const usageIds: string[] = []
const result = projectAutomationList(
source,
{
...context(),
repoConnectionId: (repoId) => {
repoLookups.push(repoId)
return repoId === 'repo-1' ? null : repoId === 'repo-ssh' ? 'ssh-1' : undefined
},
sshTargetGeneration: (targetId) => {
targetLookups.push(targetId)
return targetId === 'ssh-1' ? 7 : undefined
},
usageSummary: (id) => {
usageIds.push(id)
return null
}
},
{ kind: 'self' }
)
expect(collectionMethodReads).toEqual([])
expect(result.automations.map((entry) => entry.id)).toEqual(['local-1'])
expect(result.items.map((item) => item.automationId)).toEqual(['local-1'])
expect(result.items[0]?.selector).toEqual({ kind: 'self' })
expect(result.orphanCount).toBe(1)
expect(repoLookups).toEqual(['repo-1'])
expect(targetLookups).toEqual(['ssh-1', 'ssh-gone'])
expect(usageIds).toEqual(['local-1'])
})
})
describe('automationChangePublications', () => {
it('publishes one event when the record stays on its host', () => {
expect(automationChangePublications({ kind: 'self' }, { kind: 'self' })).toEqual([
{ kind: 'self' }
])
})
it('publishes source and destination when the record moves', () => {
expect(automationChangePublications({ kind: 'self' }, { kind: 'ssh', targetId: 't1' })).toEqual(
[{ kind: 'self' }, { kind: 'ssh', targetId: 't1' }]
)
})
it('treats two SSH hosts as a move', () => {
expect(
automationChangePublications({ kind: 'ssh', targetId: 't1' }, { kind: 'ssh', targetId: 't2' })
).toEqual([
{ kind: 'ssh', targetId: 't1' },
{ kind: 'ssh', targetId: 't2' }
])
})
// Why: a partial publication would leave the unnamed side showing a row that moved away.
it('degrades to one unscoped event when either side is unknown', () => {
expect(automationChangePublications(null, { kind: 'self' })).toEqual([undefined])
expect(automationChangePublications({ kind: 'self' }, null)).toEqual([undefined])
})
it('drops the incarnation when narrowing a projected selector', () => {
expect(
toAutomationChangeSelector({ kind: 'ssh', targetId: 't1', targetGeneration: 9 })
).toEqual({ kind: 'ssh', targetId: 't1' })
expect(toAutomationChangeSelector({ kind: 'orphan', issue: 'gone' })).toEqual({
kind: 'orphan'
})
})
})
// Folder workspace pins decide actual dispatch hosts; repo-only projection mislabeled SSH runs as Self.
describe('workspace host pin', () => {
it('files a local record under the SSH host its workspace pins', () => {
const selector = projectAutomationSelector(
automation({ workspaceId: 'folder:fw-1' }),
context({ workspaceHost: () => ({ kind: 'ssh', targetId: 'ssh-1' }) })
)
expect(selector).toEqual({ kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 })
})
it('orphans a pin whose SSH target is no longer registered', () => {
const selector = projectAutomationSelector(
automation({ workspaceId: 'folder:fw-1' }),
@@ -305,22 +131,4 @@ describe('workspace host pin', () => {
issue: AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous
})
})
it('leaves the repo in charge when nothing pins the workspace', () => {
expect(
projectAutomationSelector(
automation(),
context({ workspaceHost: () => ({ kind: 'unpinned' }) })
)
).toEqual({ kind: 'self' })
expect(
projectAutomationSelector(
automation({
projectId: 'repo-ssh',
runContext: { repoId: 'repo-ssh' }
} as Partial<Automation>),
context({ workspaceHost: () => ({ kind: 'local' }) })
)
).toEqual({ kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.malformed })
})
})
+1 -69
View File
@@ -1,24 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
hostStableKey,
isSameAutomationOwner,
isSameStableAutomationHost,
ownerKey,
parseHostStableKey,
toStableCatalogRef
} from './automation-owner-key'
import { hostStableKey, isSameAutomationOwner, parseHostStableKey } from './automation-owner-key'
import type { AutomationOwnerRef, StableAutomationCatalogRef } from './automation-owner-ref'
const desktopSelf: AutomationOwnerRef = {
authority: { kind: 'desktop' },
selector: { kind: 'self' }
}
const desktopSsh = (targetId: string, targetGeneration: number): AutomationOwnerRef => ({
authority: { kind: 'desktop' },
selector: { kind: 'ssh', targetId, targetGeneration }
})
const runtimeSsh = (
environmentId: string,
pairingRevision: number,
@@ -55,38 +38,9 @@ describe('automation owner key injectivity', () => {
hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'a:b' } })
)
})
it('separates desktop from runtime for the same selector', () => {
expect(ownerKey(desktopSelf)).not.toBe(
ownerKey({
authority: { kind: 'runtime', environmentId: 'e1', pairingRevision: 1 },
selector: { kind: 'self' }
})
)
})
it('separates the same target id under desktop and under a runtime', () => {
expect(ownerKey(desktopSsh('t1', 4))).not.toBe(ownerKey(runtimeSsh('e1', 1, 't1', 4)))
})
it('keeps stable and owner keys in separate namespaces', () => {
expect(ownerKey(desktopSelf)).not.toBe(hostStableKey(toStableCatalogRef(desktopSelf)))
})
})
describe('incarnation sensitivity', () => {
it('stable key ignores pairing revision and target generation', () => {
expect(hostStableKey(toStableCatalogRef(runtimeSsh('e1', 1, 't1', 4)))).toBe(
hostStableKey(toStableCatalogRef(runtimeSsh('e1', 9, 't1', 7)))
)
expect(
isSameStableAutomationHost(
toStableCatalogRef(runtimeSsh('e1', 1, 't1', 4)),
toStableCatalogRef(runtimeSsh('e1', 9, 't1', 7))
)
).toBe(true)
})
it('owner key does not ignore them', () => {
expect(isSameAutomationOwner(runtimeSsh('e1', 1, 't1', 4), runtimeSsh('e1', 1, 't1', 4))).toBe(
true
@@ -98,31 +52,9 @@ describe('incarnation sensitivity', () => {
false
)
})
it('drops incarnations when projecting an owner to a stable ref', () => {
expect(toStableCatalogRef(runtimeSsh('e1', 3, 't1', 4))).toEqual({
authority: { kind: 'runtime', environmentId: 'e1' },
selector: { kind: 'ssh', targetId: 't1' }
})
})
})
describe('parseHostStableKey', () => {
const refs: StableAutomationCatalogRef[] = [
{ authority: { kind: 'desktop' }, selector: { kind: 'self' } },
{ authority: { kind: 'desktop' }, selector: { kind: 'orphan' } },
{ authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'weird:id/with spaces' } },
{ authority: { kind: 'runtime', environmentId: 'env:1' }, selector: { kind: 'self' } },
{ authority: { kind: 'runtime', environmentId: 'env 1' }, selector: { kind: 'orphan' } },
{ authority: { kind: 'runtime', environmentId: 'e' }, selector: { kind: 'ssh', targetId: 't' } }
]
it('round-trips every catalog ref shape', () => {
for (const ref of refs) {
expect(parseHostStableKey(hostStableKey(ref))).toEqual(ref)
}
})
it.each([
'',
'host',
@@ -1,64 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Automation } from './automations-types'
import {
getAutomationLegacyRepoId,
getAutomationRunProjectId,
getAutomationRunRepoId
} from './automation-run-identity'
function automation(overrides: Partial<Automation> = {}): Automation {
return {
id: 'auto-1',
name: 'Automation',
prompt: 'Run this',
precheck: null,
agentId: 'claude',
runContext: null,
sourceContext: null,
projectId: 'legacy-repo',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: 1,
enabled: true,
nextRunAt: 1,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
describe('automation run identity', () => {
it('uses explicit run context identity when present', () => {
const value = automation({
runContext: {
kind: 'workspace-run',
projectId: 'github:stablyai/orca',
hostId: 'ssh:builder',
projectHostSetupId: 'setup-builder',
repoId: 'remote-repo',
path: '/remote/orca'
}
})
expect(getAutomationLegacyRepoId(value)).toBe('legacy-repo')
expect(getAutomationRunRepoId(value)).toBe('remote-repo')
expect(getAutomationRunProjectId(value)).toBe('github:stablyai/orca')
})
it('falls back to the legacy repo id for pre-host-context automations', () => {
const value = automation()
expect(getAutomationLegacyRepoId(value)).toBe('legacy-repo')
expect(getAutomationRunRepoId(value)).toBe('legacy-repo')
expect(getAutomationRunProjectId(value)).toBe('legacy-repo')
})
})
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { AutomationRun } from './automations-types'
import {
MAX_AUTOMATION_RUNS_PER_AUTOMATION,
backfillAutomationRunNumbers,
nextAutomationRunNumber,
pruneAutomationRuns
@@ -39,11 +38,6 @@ function makeRuns(automationId: string, count: number, from = 0): AutomationRun[
}
describe('pruneAutomationRuns', () => {
it('keeps everything below the cap', () => {
const runs = makeRuns('a', 5)
expect(pruneAutomationRuns(runs, 10)).toEqual(runs)
})
it('keeps only the newest N per automation', () => {
const kept = pruneAutomationRuns(makeRuns('a', 10), 3)
expect(kept.map((r) => r.id)).toEqual(['a-7', 'a-8', 'a-9'])
@@ -57,25 +51,6 @@ describe('pruneAutomationRuns', () => {
expect(kept.filter((r) => r.automationId === 'b')).toHaveLength(2)
})
it('preserves original append order among survivors', () => {
const runs = [...makeRuns('a', 4), ...makeRuns('b', 4)]
const kept = pruneAutomationRuns(runs, 2)
expect(kept.map((r) => r.id)).toEqual(['a-2', 'a-3', 'b-2', 'b-3'])
})
it('breaks createdAt ties on scheduledFor so pruning is deterministic', () => {
const runs = [
run({ id: 'x', automationId: 'a', createdAt: 5, scheduledFor: 1 }),
run({ id: 'y', automationId: 'a', createdAt: 5, scheduledFor: 2 })
]
expect(pruneAutomationRuns(runs, 1).map((r) => r.id)).toEqual(['y'])
})
it('returns nothing when the cap is zero or negative', () => {
expect(pruneAutomationRuns(makeRuns('a', 3), 0)).toEqual([])
expect(pruneAutomationRuns(makeRuns('a', 3), -1)).toEqual([])
})
// Dispatched completions may arrive hours later; retain their rows or updateAutomationRun throws.
it('never evicts in-flight runs, even far past the cap', () => {
const inFlight = [
@@ -93,45 +68,9 @@ describe('pruneAutomationRuns', () => {
'a-9'
])
})
it('shrinks a realistic runaway history to the cap', () => {
const runaway = [
...makeRuns('a', 2796),
...makeRuns('b', 2796),
...makeRuns('c', 2796),
...makeRuns('d', 2796)
]
expect(runaway).toHaveLength(11_184)
expect(pruneAutomationRuns(runaway)).toHaveLength(4 * MAX_AUTOMATION_RUNS_PER_AUTOMATION)
})
})
describe('backfillAutomationRunNumbers', () => {
it('numbers legacy runs by append position within their automation', () => {
const runs = [
run({ id: 'a-0', automationId: 'a' }),
run({ id: 'b-0', automationId: 'b' }),
run({ id: 'a-1', automationId: 'a' })
]
expect(backfillAutomationRunNumbers(runs).map((r) => r.runNumber)).toEqual([1, 1, 2])
})
it('leaves an existing runNumber untouched', () => {
const runs = [run({ id: 'a-0', automationId: 'a', runNumber: 42 })]
expect(backfillAutomationRunNumbers(runs)[0].runNumber).toBe(42)
})
// Downgrades append unnumbered runs after high-numbered survivors; append-position numbering would duplicate retained titles.
it('never reissues a number a numbered run already holds', () => {
const runs = [
run({ id: 'a-0', automationId: 'a', runNumber: 2 }),
run({ id: 'a-1', automationId: 'a' })
]
const numbers = backfillAutomationRunNumbers(runs).map((r) => r.runNumber)
expect(numbers).toEqual([2, 3])
expect(new Set(numbers).size).toBe(numbers.length)
})
it('numbers unnumbered runs above the highest survivor, per automation', () => {
const runs = [
run({ id: 'a-0', automationId: 'a', runNumber: 200 }),
@@ -151,42 +90,4 @@ describe('nextAutomationRunNumber', () => {
]
expect(nextAutomationRunNumber(retained)).toBe(2797)
})
it('falls back to the count for legacy runs with no numbers', () => {
expect(nextAutomationRunNumber(makeRuns('a', 100))).toBe(101)
})
it('starts at 1 for a brand new automation', () => {
expect(nextAutomationRunNumber([])).toBe(1)
})
it('never repeats a number across a prune cycle', () => {
let runs = backfillAutomationRunNumbers(makeRuns('a', 250))
runs = pruneAutomationRuns(runs, 100)
const next = nextAutomationRunNumber(runs)
expect(next).toBe(251)
expect(runs.some((r) => r.runNumber === next)).toBe(false)
})
})
it('does not inspect ordering timestamps when an automation is within its retention cap', () => {
let reads = 0
const runs = Array.from({ length: 100 }, (_, index) =>
run({
id: `run-${index}`,
automationId: 'a'
})
)
for (const [index, entry] of runs.entries()) {
Object.defineProperty(entry, 'createdAt', {
get() {
reads += 1
return (index * 37) % 100
}
})
}
const kept = pruneAutomationRuns(runs)
expect(kept).toHaveLength(100)
expect(kept.every((entry, index) => entry === runs[index])).toBe(true)
expect(reads).toBe(0)
})
@@ -1,63 +1,9 @@
import { describe, expect, it } from 'vitest'
import { describeAutomationScheduleDrift } from './automation-schedule-drift'
// Record corpus outputs from parent 729491597f3 and this branch; never derive expectations from the detector.
const DRIFTED = [
'5/15 * * * *',
'0/30 * * * *',
'5/15 9 * * *',
'0 9/4 * * *',
'0 9 1/7 * *',
'0 9 * 1/3 *',
'0 9 * * 1/2',
'0 9 1-31 * 1',
'0 9 */2 * 1',
'0 9 */3 * 1',
'0 9 * MAR/3 *'
]
const STABLE = [
'0 * * * *',
'30 9 * * *',
'30 9 * * 1-5',
'30 9 * * 3',
'*/15 * * * *',
'*/5 * * * *',
'5 * * * *',
'0 9 */1 * 1',
'0 9 1,15 * 1',
'0 9 * * 0-6',
'0 9 * * 0-7',
'0 9 * * 1-7',
'0 9 */2 * *',
'0 9 * * */2',
'0 9 1-31 * *',
'0 9 * * *',
'0 9 15 * *',
'0 9 1-15 * 1',
'*/90 * * * *',
'5/90 * * * *',
'0 9 * * */8',
'0 9 * MAY MON',
'0 9 * * FRI'
]
const ANCHOR = new Date(2026, 0, 1).getTime()
describe('automation schedule drift', () => {
it('flags every schedule the repair changed', () => {
for (const expression of DRIFTED) {
expect(describeAutomationScheduleDrift(expression, ANCHOR), expression).not.toBeNull()
}
})
// Changed restriction flags without changed fire dates must stay silent or the notice becomes noise.
it('stays silent on schedules the repair left alone', () => {
for (const expression of STABLE) {
expect(describeAutomationScheduleDrift(expression, ANCHOR), expression).toBeNull()
}
})
it('reports the direction and size of the change', () => {
// 1x/hour -> 4x/hour: the cadence users will feel as spend.
expect(describeAutomationScheduleDrift('5/15 * * * *', ANCHOR)).toEqual({
@@ -69,13 +15,4 @@ describe('automation schedule drift', () => {
const fewer = describeAutomationScheduleDrift('0 9 */2 * 1', ANCHOR)
expect(fewer!.currentRunsPerYear).toBeLessThan(fewer!.previousRunsPerYear / 4)
})
it('ignores RRULE presets, which never used the repaired parser', () => {
expect(describeAutomationScheduleDrift('FREQ=DAILY;BYHOUR=9;BYMINUTE=0', ANCHOR)).toBeNull()
})
it('reports nothing for a schedule that cannot be read at all', () => {
expect(describeAutomationScheduleDrift('0 9 32 * *', ANCHOR)).toBeNull()
expect(describeAutomationScheduleDrift('not a cron', ANCHOR)).toBeNull()
})
})
+1 -160
View File
@@ -1,11 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describeAutomationSchedule, formatAutomationSchedule } from './automation-schedules'
import {
classifyAutomationCronSchedule,
describeAutomationSchedule,
formatAutomationSchedule
} from './automation-schedules'
import {
buildAutomationCronSchedule,
buildAutomationRrule,
latestAutomationOccurrenceAtOrBefore,
nextAutomationOccurrenceAfter
@@ -19,15 +14,6 @@ import {
tryParseAutomationRrule
} from './automation-schedule-parsing'
function formatTimeForTest(hour: number, minute: number): string {
const date = new Date()
date.setHours(hour, minute, 0, 0)
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit'
}).format(date)
}
afterEach(() => {
vi.restoreAllMocks()
})
@@ -65,26 +51,6 @@ describe('automation schedules', () => {
expect(new Date(next).getMinutes()).toBe(30)
})
it('round-trips a weekly schedule for editing', () => {
const rrule = buildAutomationRrule({ preset: 'weekly', hour: 16, minute: 45, dayOfWeek: 3 })
expect(parseAutomationRrule(rrule)).toEqual({
preset: 'weekly',
hour: 16,
minute: 45,
dayOfWeek: 3
})
})
it('round-trips Sunday weekly schedules without coercing them to Monday', () => {
const rrule = buildAutomationRrule({ preset: 'weekly', hour: 10, minute: 15, dayOfWeek: 0 })
expect(parseAutomationRrule(rrule)).toEqual({
preset: 'weekly',
hour: 10,
minute: 15,
dayOfWeek: 0
})
})
it('rejects malformed weekly BYDAY instead of remapping to Sunday', () => {
expect(() => parseAutomationRrule('FREQ=WEEKLY;BYDAY=NO;BYHOUR=10;BYMINUTE=15')).toThrow(
'Invalid recurrence day.'
@@ -105,10 +71,6 @@ describe('automation schedules', () => {
).toThrow('Invalid recurrence day.')
})
it('formats invalid schedules with a safe fallback label', () => {
expect(formatAutomationSchedule('FREQ=YEARLY')).toBe('Invalid schedule')
})
it.each([
['TU,TH,SA', new Date(2026, 9, 6, 8)],
['MO,WE,FR,SU', new Date(2026, 9, 7, 8)],
@@ -129,15 +91,6 @@ describe('automation schedules', () => {
}
)
it.each(['FREQ=WEEKLY;BYDAY=TU,NO;BYHOUR=8;BYMINUTE=0', 'FREQ=YEARLY', '0 0 31 2 *'])(
'keeps the invalid label for schedules the scheduler rejects: %s',
(schedule) => {
expect(isValidAutomationSchedule(schedule)).toBe(false)
expect(formatAutomationSchedule(schedule)).toBe('Invalid schedule')
expect(describeAutomationSchedule(schedule)).toEqual({ kind: 'invalid' })
}
)
it.each([
['\u001b[2J', '\\x1b[2J'],
['\u001b]52;c;ZXZpbA==\u0007', '\\x1b]52;c;ZXZpbA==\\x07'],
@@ -153,10 +106,6 @@ describe('automation schedules', () => {
expect(describeAutomationSchedule(expression)).toEqual({ kind: 'custom', expression: expected })
})
it('formats hourly schedules using the stored minute', () => {
expect(formatAutomationSchedule('FREQ=HOURLY;BYMINUTE=5')).toBe('Hourly at :05')
})
it('computes custom cron schedules', () => {
const next = nextAutomationOccurrenceAfter(
'15 10 * * 1-5',
@@ -173,87 +122,6 @@ describe('automation schedules', () => {
expect(latest).toBe(new Date('2026-05-15T10:15:00').getTime())
})
it('builds cron schedules from simple automation presets', () => {
expect(buildAutomationCronSchedule({ preset: 'hourly', hour: 9, minute: 15 })).toBe(
'15 * * * *'
)
expect(buildAutomationCronSchedule({ preset: 'daily', hour: 9, minute: 15 })).toBe('15 9 * * *')
expect(buildAutomationCronSchedule({ preset: 'weekdays', hour: 9, minute: 15 })).toBe(
'15 9 * * 1-5'
)
expect(
buildAutomationCronSchedule({ preset: 'weekly', hour: 9, minute: 15, dayOfWeek: 0 })
).toBe('15 9 * * 0')
})
// Why: shared labels feed the CLI, so they must stay English on any OS locale (#14404).
it('formats schedule labels without reading the OS weekday names', () => {
const nativeDateTimeFormat = Intl.DateTimeFormat
const dateTimeFormat = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(function (
...args: ConstructorParameters<typeof Intl.DateTimeFormat>
) {
return new nativeDateTimeFormat(...args)
} as unknown as typeof Intl.DateTimeFormat)
const weeklyRrule = buildAutomationRrule({ preset: 'weekly', hour: 9, minute: 0, dayOfWeek: 5 })
expect(formatAutomationSchedule('30 12 * * 7')).toBe(`Sundays at ${formatTimeForTest(12, 30)}`)
expect(formatAutomationSchedule(weeklyRrule)).toBe(`Fridays at ${formatTimeForTest(9, 0)}`)
expect(classifyAutomationCronSchedule('30 12 * * 7').label).toBe(
`Sundays at ${formatTimeForTest(12, 30)}`
)
expect(
dateTimeFormat.mock.calls.filter(([, options]) => options?.weekday !== undefined)
).toHaveLength(0)
})
it('exposes a locale-free descriptor for callers that render their own copy', () => {
expect(describeAutomationSchedule('30 12 * * 7')).toEqual({
kind: 'weekly',
hour: 12,
minute: 30,
dayOfWeek: 0
})
expect(describeAutomationSchedule('5 * * * *')).toEqual({ kind: 'hourly', minute: 5 })
expect(describeAutomationSchedule('15 10 * * MON-FRI')).toEqual({
kind: 'weekdays',
hour: 10,
minute: 15
})
expect(describeAutomationSchedule('FREQ=DAILY;BYHOUR=9;BYMINUTE=0')).toEqual({
kind: 'daily',
hour: 9,
minute: 0
})
expect(describeAutomationSchedule('FREQ=WEEKLY;BYDAY=FR;BYHOUR=9;BYMINUTE=0')).toEqual({
kind: 'weekly',
hour: 9,
minute: 0,
dayOfWeek: 5
})
expect(describeAutomationSchedule('*/30 9-17 * * MON-FRI')).toEqual({ kind: 'custom' })
expect(describeAutomationSchedule('FREQ=YEARLY')).toEqual({ kind: 'invalid' })
})
it('formats simple cron schedules with friendly labels', () => {
expect(formatAutomationSchedule('5 * * * *')).toBe('Hourly at :05')
expect(formatAutomationSchedule('15 10 * * *')).toBe(`Daily at ${formatTimeForTest(10, 15)}`)
expect(formatAutomationSchedule('15 10 * * MON-FRI')).toBe(
`Weekdays at ${formatTimeForTest(10, 15)}`
)
expect(formatAutomationSchedule('30 12 * * 7')).toBe(`Sundays at ${formatTimeForTest(12, 30)}`)
})
it('tokenizes pasted cron whitespace without regex field splitting', () => {
const split = vi.spyOn(String.prototype, 'split')
const schedule = ['15', String.fromCharCode(160), '10\n*\t*\rMON-FRI'].join('')
expect(getAutomationCronExpressionFields(schedule)).toEqual(['15', '10', '*', '*', 'MON-FRI'])
expect(formatAutomationSchedule(schedule)).toBe(`Weekdays at ${formatTimeForTest(10, 15)}`)
expect(
split.mock.calls.filter(([pattern]) => pattern instanceof RegExp && pattern.source === '\\s+')
).toHaveLength(0)
})
it('rejects oversized pasted cron expressions before field tokenization', () => {
const split = vi.spyOn(String.prototype, 'split')
const oversizedSchedule = 'secret-cron-field '.repeat(AUTOMATION_CRON_EXPRESSION_MAX_BYTES)
@@ -265,27 +133,6 @@ describe('automation schedules', () => {
).toHaveLength(0)
})
it('classifies simple cron schedules for provider edit flows', () => {
expect(classifyAutomationCronSchedule('15 10 * * MON-FRI')).toMatchObject({
kind: 'weekdays',
hour: 10,
minute: 15
})
expect(classifyAutomationCronSchedule('30 12 * * 7')).toMatchObject({
kind: 'weekly',
hour: 12,
minute: 30,
dayOfWeek: 0
})
})
it('labels valid unsupported cron schedules as custom schedules', () => {
expect(formatAutomationSchedule('*/30 9-17 * * MON-FRI')).toBe('Custom schedule')
expect(formatAutomationSchedule('0 9 1 * *')).toBe('Custom schedule')
expect(formatAutomationSchedule('0 9 1 * MON')).toBe('Custom schedule')
expect(formatAutomationSchedule('0 9,17 * * MON-FRI')).toBe('Custom schedule')
})
// Star-step */1 stays unrestricted, preserving AND and Monday-only firing (#15896).
it('treats a stepped cron day-of-month field as unrestricted for DOM/DOW matching', () => {
const next = nextAutomationOccurrenceAfter(
@@ -308,12 +155,6 @@ describe('automation schedules', () => {
expect(formatAutomationSchedule('0 0 31 2 *')).toBe('Invalid schedule')
})
it('rejects RRULE input with the cron-only validator', () => {
const rrule = buildAutomationRrule({ preset: 'daily', hour: 9, minute: 0 })
expect(isValidAutomationSchedule(rrule)).toBe(true)
expect(isValidAutomationCronSchedule(rrule)).toBe(false)
})
it('finds rare but valid leap-day custom cron schedules', () => {
const next = nextAutomationOccurrenceAfter(
'0 0 29 2 *',
@@ -1,81 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { AutomationRun, AutomationRunUsage } from './automations-types'
import { summarizeAutomationRunUsage } from './automation-usage-summary'
const makeUsage = (overrides: Partial<AutomationRunUsage> = {}): AutomationRunUsage => ({
status: 'known',
provider: 'claude',
model: 'sonnet',
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 3,
cacheWriteTokens: 2,
reasoningOutputTokens: 1,
totalTokens: 21,
estimatedCostUsd: 0.25,
estimatedCostSource: 'api_equivalent',
providerSessionId: 'session-1',
attribution: 'provider_session_time_window',
collectedAt: 1,
unavailableReason: null,
unavailableMessage: null,
...overrides
})
const makeRun = (usage: AutomationRunUsage | null): AutomationRun =>
({ id: 'run-1', automationId: 'a1', usage }) as AutomationRun
describe('summarizeAutomationRunUsage', () => {
it('aggregates known runs and counts the rest as unavailable', () => {
const summary = summarizeAutomationRunUsage([
makeRun(makeUsage()),
makeRun(makeUsage({ estimatedCostUsd: null })),
makeRun(makeUsage({ status: 'unavailable' })),
makeRun(null)
])
expect(summary).toEqual({
knownRuns: 2,
unavailableRuns: 2,
inputTokens: 20,
outputTokens: 10,
cacheTokens: 10,
reasoningOutputTokens: 2,
totalTokens: 42,
estimatedCostUsd: 0.25,
lastRunStatus: null,
lastRunAt: null
})
})
it('reports an unknown cost rather than zero when no run has one', () => {
const summary = summarizeAutomationRunUsage([makeRun(makeUsage({ estimatedCostUsd: null }))])
expect(summary.estimatedCostUsd).toBeNull()
expect(summary.knownRuns).toBe(1)
})
it('summarizes an empty retained history without inventing usage', () => {
expect(summarizeAutomationRunUsage([])).toEqual({
knownRuns: 0,
unavailableRuns: 0,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
estimatedCostUsd: null,
lastRunStatus: null,
lastRunAt: null
})
})
it('projects the newest retained run so list filters never fetch history', () => {
const summary = summarizeAutomationRunUsage([
{ ...makeRun(makeUsage()), createdAt: 1, status: 'completed', dispatchedAt: 11 },
{ ...makeRun(makeUsage()), createdAt: 3, status: 'dispatch_failed', dispatchedAt: 33 },
{ ...makeRun(makeUsage()), createdAt: 2, status: 'completed', dispatchedAt: 22 }
])
expect(summary.lastRunStatus).toBe('dispatch_failed')
expect(summary.lastRunAt).toBe(33)
})
})
-16
View File
@@ -1,16 +0,0 @@
import { describe, expect, it } from 'vitest'
import { deriveLegacyLocalBranchName, legacyBaseRefSearchResult } from './base-ref-search-result'
describe('legacyBaseRefSearchResult', () => {
it('derives local branch names for common remote refs returned by older runtimes', () => {
expect(deriveLegacyLocalBranchName('origin/feature/something')).toBe('feature/something')
expect(deriveLegacyLocalBranchName('upstream/release/1.2')).toBe('release/1.2')
})
it('keeps local branch refs unchanged when a remote prefix is not known', () => {
expect(legacyBaseRefSearchResult('feature/something')).toEqual({
refName: 'feature/something',
localBranchName: 'feature/something'
})
})
})
-26
View File
@@ -1,26 +0,0 @@
import { describe, expect, it } from 'vitest'
import { hasBinaryFileExtension } from './binary-file-extensions'
describe('hasBinaryFileExtension', () => {
it('matches known binary extensions case-insensitively', () => {
expect(hasBinaryFileExtension('docs/Shot.PNG')).toBe(true)
expect(hasBinaryFileExtension('vendor/lib.tar.gz')).toBe(true)
expect(hasBinaryFileExtension('C:\\assets\\theme.woff2')).toBe(true)
})
it('treats svg as text because the diff view renders its source', () => {
expect(hasBinaryFileExtension('assets/map.svg')).toBe(false)
})
it('rejects text files, dotfiles, and extensionless paths', () => {
expect(hasBinaryFileExtension('src/index.ts')).toBe(false)
expect(hasBinaryFileExtension('.png')).toBe(false)
expect(hasBinaryFileExtension('scripts/.png')).toBe(false)
expect(hasBinaryFileExtension('Makefile')).toBe(false)
expect(hasBinaryFileExtension(undefined)).toBe(false)
})
it('does not match an extension that only appears in a directory name', () => {
expect(hasBinaryFileExtension('build.zip/manifest')).toBe(false)
})
})
-169
View File
@@ -2,25 +2,6 @@ import { describe, expect, it, vi } from 'vitest'
import { BoundedMap } from './bounded-map'
describe('BoundedMap', () => {
it('rejects an invalid maxEntries', () => {
expect(() => new BoundedMap<string, number>({ maxEntries: 0 })).toThrow(RangeError)
expect(() => new BoundedMap<string, number>({ maxEntries: 4, maxBytes: 10 })).toThrow(/sizeOf/)
})
it('evicts the least-recently-used entry past the count ceiling', () => {
const evicted: string[] = []
const m = new BoundedMap<string, number>({ maxEntries: 3, onEvict: (_v, k) => evicted.push(k) })
m.set('a', 1)
m.set('b', 2)
m.set('c', 3)
expect(m.size).toBe(3)
m.set('d', 4) // count+1 -> evict oldest 'a'
expect(m.size).toBe(3)
expect(evicted).toEqual(['a'])
expect(m.has('a')).toBe(false)
expect([...m.keys()]).toEqual(['b', 'c', 'd'])
})
it('get() marks recently-used so it survives eviction', () => {
const m = new BoundedMap<string, number>({ maxEntries: 3 })
m.set('a', 1)
@@ -48,45 +29,6 @@ describe('BoundedMap', () => {
expect(m.has('c')).toBe(true)
})
it('rejects a single value larger than maxBytes without wiping the map', () => {
const m = new BoundedMap<string, string>({
maxEntries: 10,
maxBytes: 4,
sizeOf: (v) => v.length
})
expect(m.set('a', 'ok')).toBe(true)
expect(m.set('big', 'toolong')).toBe(false)
expect(m.has('a')).toBe(true)
expect(m.has('big')).toBe(false)
})
it('updates retained bytes on overwrite and delete', () => {
const m = new BoundedMap<string, string>({
maxEntries: 10,
maxBytes: 100,
sizeOf: (v) => v.length
})
m.set('a', 'xxx') // 3
m.set('a', 'x') // overwrite -> 1
expect(m.retainedBytes).toBe(1)
m.delete('a')
expect(m.retainedBytes).toBe(0)
expect(m.size).toBe(0)
})
it('maxEntryBytes rejects an oversized single entry independent of the aggregate', () => {
const m = new BoundedMap<string, string>({
maxEntries: 10,
maxBytes: 100,
maxEntryBytes: 4,
sizeOf: (v) => v.length
})
expect(m.set('a', 'ok')).toBe(true)
expect(m.set('b', 'toolong')).toBe(false) // 7 > maxEntryBytes 4, though aggregate has room
expect(m.has('a')).toBe(true)
expect(m.retainedBytes).toBe(2)
})
it('never reports success for an entry it did not retain', () => {
const onEvict = vi.fn()
// An entry ceiling above total once admitted then evicted the same key while set returned true.
@@ -122,16 +64,6 @@ describe('BoundedMap', () => {
expect(m.set('huge', 'zzzzzzzzzzzz')).toBe(false)
})
it('rejects invalid ceilings at construction', () => {
const sizeOf = (v: string): number => v.length
expect(
() => new BoundedMap<string, string>({ maxEntries: 2, maxBytes: Number.NaN, sizeOf })
).toThrow(RangeError)
expect(() => new BoundedMap<string, string>({ maxEntries: 2, maxBytes: -1, sizeOf })).toThrow(
RangeError
)
})
it('a rejected oversized overwrite leaves the existing value untouched', () => {
const m = new BoundedMap<string, string>({
maxEntries: 10,
@@ -161,38 +93,6 @@ describe('BoundedMap', () => {
])
})
it('peek() reads without changing eviction order', () => {
const m = new BoundedMap<string, number>({ maxEntries: 2 })
m.set('a', 1)
m.set('b', 2)
expect(m.peek('a')).toBe(1) // does NOT mark 'a' recently used
m.set('c', 3)
expect(m.has('a')).toBe(false)
expect(m.has('b')).toBe(true)
})
it('stores an undefined value as a present key', () => {
const m = new BoundedMap<string, number | undefined>({ maxEntries: 2 })
expect(m.set('u', undefined)).toBe(true)
expect(m.has('u')).toBe(true)
expect(m.get('u')).toBeUndefined()
expect(m.keys()).toEqual(['u'])
})
it('keeps the byte ledger exact at the safe-integer boundary', () => {
const m = new BoundedMap<string, number>({
maxEntries: 10,
maxBytes: Number.MAX_SAFE_INTEGER,
sizeOf: (v) => v
})
expect(m.set('a', Number.MAX_SAFE_INTEGER)).toBe(true)
// would overflow the aggregate past MAX_SAFE_INTEGER -> rejected, ledger untouched
expect(m.set('b', 1)).toBe(false)
expect(m.retainedBytes).toBe(Number.MAX_SAFE_INTEGER)
m.delete('a')
expect(m.retainedBytes).toBe(0)
})
it('an overflow-rejected overwrite keeps the prior value and leaves no orphaned weight', () => {
const weights = new Map<string, number>([
['a', Number.MAX_SAFE_INTEGER - 1],
@@ -218,29 +118,6 @@ describe('BoundedMap', () => {
expect(m.size).toBe(0)
})
it('rejects fractional and unsafe weights and ceilings', () => {
const sizeOf = (v: number): number => v
expect(() => new BoundedMap<string, number>({ maxEntries: 2, maxBytes: 1.5, sizeOf })).toThrow(
RangeError
)
const m = new BoundedMap<string, number>({ maxEntries: 4, maxBytes: 100, sizeOf })
expect(m.set('frac', 0.5)).toBe(false)
expect(m.set('inf', Number.POSITIVE_INFINITY)).toBe(false)
expect(m.retainedBytes).toBe(0)
})
it('protects a just-admitted NaN key from eviction', () => {
const m = new BoundedMap<number, string>({
maxEntries: 2,
maxBytes: 6,
sizeOf: (v) => v.length
})
m.set(1, 'aa')
m.set(2, 'bb')
expect(m.set(Number.NaN, 'cc')).toBe(true)
expect(m.has(Number.NaN)).toBe(true)
})
it('rejects the entry when sizeOf throws instead of propagating', () => {
const m = new BoundedMap<string, string>({
maxEntries: 4,
@@ -259,42 +136,6 @@ describe('BoundedMap', () => {
expect(m.retainedBytes).toBe(3)
})
it('stays within bounds even when onEvict throws', () => {
const m = new BoundedMap<string, string>({
maxEntries: 10,
maxBytes: 3,
sizeOf: (v) => v.length,
onEvict: () => {
throw new Error('dispose failed')
}
})
m.set('a', 'x')
m.set('b', 'y')
m.set('c', 'z')
expect(() => m.set('d', 'w')).toThrow('dispose failed')
// bounds restored before disposal ran, so the throw cannot strand the map over capacity
expect(m.retainedBytes).toBeLessThanOrEqual(3)
expect(m.size).toBeLessThanOrEqual(3)
})
it('reports failure when a reentrant onEvict evicts the just-admitted key', () => {
let reentered = false
const m: BoundedMap<string, string> = new BoundedMap<string, string>({
maxEntries: 1,
onEvict: () => {
if (reentered) {
return
}
reentered = true
m.set('x', 'x') // nested set evicts the outer call's entry
}
})
m.set('a', 'a')
const admitted = m.set('c', 'c')
// set() must never claim success for a key the map no longer holds
expect(admitted).toBe(m.has('c'))
})
it('runs every disposal even when one onEvict throws', () => {
const disposed: string[] = []
const m = new BoundedMap<string, string>({
@@ -314,14 +155,4 @@ describe('BoundedMap', () => {
expect(disposed).toEqual(['a', 'b']) // 'b' still disposed despite the earlier throw
expect(m.retainedBytes).toBeLessThanOrEqual(2)
})
it('does not fire onEvict on explicit delete/clear', () => {
const onEvict = vi.fn()
const m = new BoundedMap<string, number>({ maxEntries: 5, onEvict })
m.set('a', 1)
m.delete('a')
m.set('b', 2)
m.clear()
expect(onEvict).not.toHaveBeenCalled()
})
})
@@ -1,74 +0,0 @@
import { runInNewContext } from 'node:vm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { buildBrowserAnnotationViewportBridgeScript } from './browser-annotation-viewport-bridge'
const guestAborts: (() => Promise<void>)[] = []
afterEach(async () => {
await Promise.all(guestAborts.splice(0).map((abort) => abort()))
})
async function createGuest() {
const { Window } = await import('happy-dom')
const window = new Window()
guestAborts.push(() => window.happyDOM.abort())
const requestAnimationFrame = vi.fn(() => 17)
const cancelAnimationFrame = vi.fn()
const context = { window, document: window.document, requestAnimationFrame, cancelAnimationFrame }
const install = (enabled = true): void => {
runInNewContext(
buildBrowserAnnotationViewportBridgeScript({
emitViewport: false,
enabled,
token: 'lifecycle-test-token',
markers: [
{
id: 'saved-note',
index: 0,
isFixed: false,
rectPage: { x: 10, y: 20, width: 80, height: 30 },
rectViewport: { x: 10, y: 20, width: 80, height: 30 }
}
]
}),
context
)
}
const markerHosts = (): number =>
window.document.querySelectorAll('[data-orca-browser-annotation-overlay]').length
return { window, context, install, markerHosts, requestAnimationFrame, cancelAnimationFrame }
}
describe('browser annotation guest document lifecycle', () => {
it('retires visible markers before a replacement document finishes loading', async () => {
const guest = await createGuest()
guest.install()
expect(guest.markerHosts()).toBe(1)
guest.window.dispatchEvent(new guest.window.Event('beforeunload'))
expect(guest.markerHosts()).toBe(0)
expect(Reflect.has(guest.context, '__orcaBrowserAnnotationViewportBridge')).toBe(false)
expect(guest.cancelAnimationFrame).toHaveBeenCalledWith(17)
guest.requestAnimationFrame.mockClear()
guest.window.dispatchEvent(new guest.window.Event('scroll'))
guest.window.document.dispatchEvent(new guest.window.Event('scroll'))
guest.window.dispatchEvent(new guest.window.Event('resize'))
expect(guest.requestAnimationFrame).not.toHaveBeenCalled()
})
it('removes the old unload listener when disabled and can install fresh markers', async () => {
const guest = await createGuest()
guest.install()
guest.install(false)
expect(guest.markerHosts()).toBe(0)
guest.install()
expect(guest.markerHosts()).toBe(1)
guest.cancelAnimationFrame.mockClear()
guest.window.dispatchEvent(new guest.window.Event('beforeunload'))
expect(guest.markerHosts()).toBe(0)
expect(guest.cancelAnimationFrame).toHaveBeenCalledTimes(1)
})
})
@@ -3,35 +3,12 @@ import {
BROWSER_NETWORK_TUNNEL_MAX_DATA_BYTES,
BrowserNetworkTunnelOpcode,
decodeBrowserNetworkTunnelFrame,
decodeBrowserNetworkTunnelOpen,
decodeBrowserNetworkTunnelWindowUpdate,
encodeBrowserNetworkTunnelFrame,
encodeBrowserNetworkTunnelOpen,
encodeBrowserNetworkTunnelWindowUpdate
} from './browser-network-tunnel-protocol'
describe('browser-network-tunnel-protocol', () => {
it('round-trips remote DNS names without resolving them on the client', () => {
const encoded = encodeBrowserNetworkTunnelFrame({
opcode: BrowserNetworkTunnelOpcode.Open,
tunnelGeneration: 7,
streamId: 19,
payload: encodeBrowserNetworkTunnelOpen({ host: 'service.internal.', port: 8443 })
})
const decoded = decodeBrowserNetworkTunnelFrame(encoded)
expect(decoded).toMatchObject({
opcode: BrowserNetworkTunnelOpcode.Open,
tunnelGeneration: 7,
streamId: 19
})
expect(decoded && decodeBrowserNetworkTunnelOpen(decoded.payload)).toEqual({
host: 'service.internal.',
port: 8443
})
})
it('round-trips exact credit without accepting zero or overflow', () => {
const payload = encodeBrowserNetworkTunnelWindowUpdate(256 * 1024)
@@ -75,20 +52,6 @@ describe('browser-network-tunnel-protocol', () => {
).toThrow('payload')
})
it('rejects invalid open targets and payloads on control-only opcodes', () => {
expect(() => encodeBrowserNetworkTunnelOpen({ host: '', port: 80 })).toThrow('host')
expect(() => encodeBrowserNetworkTunnelOpen({ host: 'localhost', port: 0 })).toThrow('port')
expect(() =>
encodeBrowserNetworkTunnelFrame({
opcode: BrowserNetworkTunnelOpcode.Opened,
tunnelGeneration: 1,
streamId: 2,
payload: new Uint8Array([1])
})
).toThrow('empty')
})
it('rejects identities that cannot be represented as unsigned 32-bit values', () => {
const payload = new Uint8Array()
expect(() =>