mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
test: remove 295 low-value tests (#39494)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,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) => ({
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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,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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(() =>
|
||||
|
||||
Reference in New Issue
Block a user