diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index a124f716a1c..8312207fe75 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -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 diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 77c75e7f737..30513e7ffc6 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -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, diff --git a/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs b/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs index 80ade2ab259..605ea1c67b0 100644 --- a/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs +++ b/config/scripts/test-runtime/sqlite-admission/vitest-node-runtime-files.mjs @@ -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', diff --git a/src/main/acp/acp-dialects/grok-model-catalog.test.ts b/src/main/acp/acp-dialects/grok-model-catalog.test.ts deleted file mode 100644 index e6bf4fd7398..00000000000 --- a/src/main/acp/acp-dialects/grok-model-catalog.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/main/acp/acp-dialects/omp-dialect.test.ts b/src/main/acp/acp-dialects/omp-dialect.test.ts deleted file mode 100644 index 883202fb6aa..00000000000 --- a/src/main/acp/acp-dialects/omp-dialect.test.ts +++ /dev/null @@ -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, - 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 }) - }) -}) diff --git a/src/main/acp/acp-fixture-privacy.test.ts b/src/main/acp/acp-fixture-privacy.test.ts deleted file mode 100644 index 0be9fc51a75..00000000000 --- a/src/main/acp/acp-fixture-privacy.test.ts +++ /dev/null @@ -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) - } - } - }) -}) diff --git a/src/main/acp/acp-permission-requests.test.ts b/src/main/acp/acp-permission-requests.test.ts index bbd4a133541..9859487dd5b 100644 --- a/src/main/acp/acp-permission-requests.test.ts +++ b/src/main/acp/acp-permission-requests.test.ts @@ -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) => ({ diff --git a/src/main/acp/acp-session-agent-turns.test.ts b/src/main/acp/acp-session-agent-turns.test.ts index 474ba298184..4061d0534ba 100644 --- a/src/main/acp/acp-session-agent-turns.test.ts +++ b/src/main/acp/acp-session-agent-turns.test.ts @@ -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 }) diff --git a/src/main/acp/acp-session-events.test.ts b/src/main/acp/acp-session-events.test.ts deleted file mode 100644 index 3bb30017b2f..00000000000 --- a/src/main/acp/acp-session-events.test.ts +++ /dev/null @@ -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() - ) -}) diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index 702e439dc50..f890597b032 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -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 | 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() 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() diff --git a/src/main/acp/acp-session-notifications.test.ts b/src/main/acp/acp-session-notifications.test.ts deleted file mode 100644 index f2b52921df2..00000000000 --- a/src/main/acp/acp-session-notifications.test.ts +++ /dev/null @@ -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 - }) - }) -}) diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts deleted file mode 100644 index b4bd0b4db1b..00000000000 --- a/src/main/acp/acp-structured-auth.test.ts +++ /dev/null @@ -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 - 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([]) - }) -}) diff --git a/src/main/acp/acp-structured-electron-free.test.ts b/src/main/acp/acp-structured-electron-free.test.ts deleted file mode 100644 index 5412254b1ca..00000000000 --- a/src/main/acp/acp-structured-electron-free.test.ts +++ /dev/null @@ -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) -}) diff --git a/src/main/acp/acp-structured-prompt-failure.test.ts b/src/main/acp/acp-structured-prompt-failure.test.ts deleted file mode 100644 index 30a9ced66d2..00000000000 --- a/src/main/acp/acp-structured-prompt-failure.test.ts +++ /dev/null @@ -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' } - }) - }) - }) -}) diff --git a/src/main/acp/acp-timeline-open-enums.test.ts b/src/main/acp/acp-timeline-open-enums.test.ts deleted file mode 100644 index fe9dadabb02..00000000000 --- a/src/main/acp/acp-timeline-open-enums.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/main/acp/acp-timeline-opencode-dialect.test.ts b/src/main/acp/acp-timeline-opencode-dialect.test.ts deleted file mode 100644 index c1655c44b18..00000000000 --- a/src/main/acp/acp-timeline-opencode-dialect.test.ts +++ /dev/null @@ -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, 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' } - }) - }) -}) diff --git a/src/main/acp/acp-timeline-routing.test.ts b/src/main/acp/acp-timeline-routing.test.ts index 4ea6c30df29..5eebba6a522 100644 --- a/src/main/acp/acp-timeline-routing.test.ts +++ b/src/main/acp/acp-timeline-routing.test.ts @@ -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() - }) }) diff --git a/src/main/acp/acp-visuals-skill.test.ts b/src/main/acp/acp-visuals-skill.test.ts deleted file mode 100644 index 3b0b4f70074..00000000000 --- a/src/main/acp/acp-visuals-skill.test.ts +++ /dev/null @@ -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 { - 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' }) - }) -}) diff --git a/src/main/acp/acp-write-queue-retention.test.ts b/src/main/acp/acp-write-queue-retention.test.ts index 1c9a5931737..bade82fa88b 100644 --- a/src/main/acp/acp-write-queue-retention.test.ts +++ b/src/main/acp/acp-write-queue-retention.test.ts @@ -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 { diff --git a/src/main/agent-hooks/agent-home-login-shell.test.ts b/src/main/agent-hooks/agent-home-login-shell.test.ts deleted file mode 100644 index 15fc0cb6081..00000000000 --- a/src/main/agent-hooks/agent-home-login-shell.test.ts +++ /dev/null @@ -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 - -// Mock shell probes so CI scheduling and profile failures cannot silently exercise the fallback. -const probe = vi.hoisted(() => ({ runProcess: vi.fn() })) -vi.mock('@orca/process-host', async (importOriginal) => ({ - ...(await importOriginal>()), - 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({}) - }) -}) diff --git a/src/main/agent-hooks/branch-rename-failure-output.test.ts b/src/main/agent-hooks/branch-rename-failure-output.test.ts deleted file mode 100644 index 6df43fa457a..00000000000 --- a/src/main/agent-hooks/branch-rename-failure-output.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src/main/agent-hooks/cursor-replay-guard.test.ts b/src/main/agent-hooks/cursor-replay-guard.test.ts index 9e55d6ceec2..82802ffd057 100644 --- a/src/main/agent-hooks/cursor-replay-guard.test.ts +++ b/src/main/agent-hooks/cursor-replay-guard.test.ts @@ -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' - ]) - }) }) diff --git a/src/main/agent-hooks/first-work-folder-rename.test.ts b/src/main/agent-hooks/first-work-folder-rename.test.ts deleted file mode 100644 index 49daf88b998..00000000000 --- a/src/main/agent-hooks/first-work-folder-rename.test.ts +++ /dev/null @@ -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 { - 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() - }) -}) diff --git a/src/main/agent-hooks/grok-replay-guard.test.ts b/src/main/agent-hooks/grok-replay-guard.test.ts index bb24fa83419..78407c2929e 100644 --- a/src/main/agent-hooks/grok-replay-guard.test.ts +++ b/src/main/agent-hooks/grok-replay-guard.test.ts @@ -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 () => { diff --git a/src/main/agent-hooks/installer-utils-remote.test.ts b/src/main/agent-hooks/installer-utils-remote.test.ts index 916848abcb3..b82ab79e7f6 100644 --- a/src/main/agent-hooks/installer-utils-remote.test.ts +++ b/src/main/agent-hooks/installer-utils-remote.test.ts @@ -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 @@ -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' diff --git a/src/main/agent-hooks/locked-executable-rename.test.ts b/src/main/agent-hooks/locked-executable-rename.test.ts index 878da7b1853..cd556e881c0 100644 --- a/src/main/agent-hooks/locked-executable-rename.test.ts +++ b/src/main/agent-hooks/locked-executable-rename.test.ts @@ -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') - }) }) diff --git a/src/main/runtime/session-tabs/headless-mobile-session-absorbed-rows.test.ts b/src/main/runtime/session-tabs/headless-mobile-session-absorbed-rows.test.ts index 9db85af6d86..2e3e1c3639b 100644 --- a/src/main/runtime/session-tabs/headless-mobile-session-absorbed-rows.test.ts +++ b/src/main/runtime/session-tabs/headless-mobile-session-absorbed-rows.test.ts @@ -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 { diff --git a/src/renderer/src/app-shell/app-root-surface-settings.test.tsx b/src/renderer/src/app-shell/app-root-surface-settings.test.tsx deleted file mode 100644 index 244f9d0d1e0..00000000000 --- a/src/renderer/src/app-shell/app-root-surface-settings.test.tsx +++ /dev/null @@ -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[0] - -describe('app root surface settings selectors', () => { - it('does not rerender for an unrelated settings replacement', () => { - const store = createStore(() => ({ 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(() => ({ 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() - }) -}) diff --git a/src/renderer/src/app-shell/app-window-chrome.test.ts b/src/renderer/src/app-shell/app-window-chrome.test.ts deleted file mode 100644 index 3c5c23b9116..00000000000 --- a/src/renderer/src/app-shell/app-window-chrome.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as AppWindowChrome from './app-window-chrome' - -async function loadChromeOnMac(): Promise { - 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') - }) -}) diff --git a/src/renderer/src/app-shell/headless-phone-window-parity.test.ts b/src/renderer/src/app-shell/headless-phone-window-parity.test.ts deleted file mode 100644 index 86bcf1087cc..00000000000 --- a/src/renderer/src/app-shell/headless-phone-window-parity.test.ts +++ /dev/null @@ -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() - 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, 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 - ) -}) diff --git a/src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts b/src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts deleted file mode 100644 index 7943330f02f..00000000000 --- a/src/renderer/src/app-shell/reconcile-hydrated-server-layout-frames-switch.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts deleted file mode 100644 index cf33aed8e3e..00000000000 --- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts b/src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts index da570b3b3b4..cdc9c2dd7f4 100644 --- a/src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts +++ b/src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts @@ -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) - }) }) diff --git a/src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts b/src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts index eb6de0f6a31..1632187546b 100644 --- a/src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts +++ b/src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts @@ -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() - }) }) diff --git a/src/renderer/src/app-shell/startup-actions-selector.test.ts b/src/renderer/src/app-shell/startup-actions-selector.test.ts deleted file mode 100644 index 45a1b9cf5df..00000000000 --- a/src/renderer/src/app-shell/startup-actions-selector.test.ts +++ /dev/null @@ -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(() => ({ ...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() - }) -}) diff --git a/src/renderer/src/app-shell/use-profile-state-save-delay-notice.sonner.test.tsx b/src/renderer/src/app-shell/use-profile-state-save-delay-notice.sonner.test.tsx index 44242495404..6e074adf0b7 100644 --- a/src/renderer/src/app-shell/use-profile-state-save-delay-notice.sonner.test.tsx +++ b/src/renderer/src/app-shell/use-profile-state-save-delay-notice.sonner.test.tsx @@ -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( - - - - ) - 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() - 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() await advanceTimers() diff --git a/src/renderer/src/app-shell/use-profile-state-save-delay-notice.test.tsx b/src/renderer/src/app-shell/use-profile-state-save-delay-notice.test.tsx index 05018d5aef7..0defd334182 100644 --- a/src/renderer/src/app-shell/use-profile-state-save-delay-notice.test.tsx +++ b/src/renderer/src/app-shell/use-profile-state-save-delay-notice.test.tsx @@ -53,22 +53,6 @@ async function resolveSnapshot(delayed: boolean): Promise { }) } -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) -}) diff --git a/src/renderer/src/app-shell/window-visibility-actions-selector.test.ts b/src/renderer/src/app-shell/window-visibility-actions-selector.test.ts deleted file mode 100644 index c886165c832..00000000000 --- a/src/renderer/src/app-shell/window-visibility-actions-selector.test.ts +++ /dev/null @@ -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(() => ({ - ...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() - }) -}) diff --git a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx index 248c2a76e4d..590589066a3 100644 --- a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx +++ b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx @@ -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) diff --git a/src/renderer/src/attention/agent-attention-policy.test.ts b/src/renderer/src/attention/agent-attention-policy.test.ts index e9c67d371bb..851ebc3c66c 100644 --- a/src/renderer/src/attention/agent-attention-policy.test.ts +++ b/src/renderer/src/attention/agent-attention-policy.test.ts @@ -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}`)) - }) }) diff --git a/src/renderer/src/attention/agent-subject-read-actions.test.ts b/src/renderer/src/attention/agent-subject-read-actions.test.ts index 51641052e8c..29255dbee5d 100644 --- a/src/renderer/src/attention/agent-subject-read-actions.test.ts +++ b/src/renderer/src/attention/agent-subject-read-actions.test.ts @@ -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 - }[] = [] - 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)) diff --git a/src/renderer/src/components/AgentProgressRing.test.tsx b/src/renderer/src/components/AgentProgressRing.test.tsx deleted file mode 100644 index 24d027913d5..00000000000 --- a/src/renderer/src/components/AgentProgressRing.test.tsx +++ /dev/null @@ -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() - 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()).toContain( - 'stroke-dasharray="8 100"' - ) - expect(renderToStaticMarkup()).toContain( - 'stroke-dasharray="0 100"' - ) - }) - - it('takes an accessible name over the bare percentage', () => { - expect( - renderToStaticMarkup() - ).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[0]['state'], progress?: number) => - renderToStaticMarkup( - - - - ) - - 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(/]*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') - }) -}) diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts deleted file mode 100644 index af4972f228d..00000000000 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ /dev/null @@ -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(/ { - 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 - 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')}"` - ) - }) -}) diff --git a/src/renderer/src/components/AgentWorkingSpinner.test.tsx b/src/renderer/src/components/AgentWorkingSpinner.test.tsx deleted file mode 100644 index 9385dc14dc1..00000000000 --- a/src/renderer/src/components/AgentWorkingSpinner.test.tsx +++ /dev/null @@ -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() - }) - 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 => { - 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() - }) - // 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() - }) - 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) - } - } - }) -}) diff --git a/src/renderer/src/components/ButtonKeyHint.test.tsx b/src/renderer/src/components/ButtonKeyHint.test.tsx deleted file mode 100644 index 669951688e4..00000000000 --- a/src/renderer/src/components/ButtonKeyHint.test.tsx +++ /dev/null @@ -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( - - ) - 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() - expect(container.firstElementChild?.textContent).toBe('') - expect(container.querySelector('svg')).toBeTruthy() - }) -}) diff --git a/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-reopen.test.tsx b/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-reopen.test.tsx index 116b242aa1b..eb1989bfe2f 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-reopen.test.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit-reopen.test.tsx @@ -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() - 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() diff --git a/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit.test.tsx b/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit.test.tsx index bdc4c805270..0097a89881c 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit.test.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.optimistic-edit.test.tsx @@ -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() - 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() - 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() - 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() 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() - const update = deferred<{ ok: false; error: string }>() - runtimeMocks.jiraGetIssueForTarget.mockReturnValue(initialLoad.promise) - runtimeMocks.jiraUpdateIssueForTarget.mockReturnValue(update.promise) - render() - 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() - 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() runtimeMocks.jiraGetIssueForTarget.mockImplementationOnce(() => initialLoad.promise) diff --git a/src/renderer/src/components/JiraIssueWorkspace.status-queue.test.tsx b/src/renderer/src/components/JiraIssueWorkspace.status-queue.test.tsx index b2737c89201..12c137ac23f 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.status-queue.test.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.status-queue.test.tsx @@ -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() - 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() - }) }) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.selection.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.selection.test.tsx index c86e075f2a3..6e79187ecaf 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartGroups.selection.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.selection.test.tsx @@ -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(':scope > :last-child')! -} - it('starts expanded; collapsing hides a node’s rows and keeps their selection', () => { render({ candidates: seedTree() }) const parent = disclosureOf('parent') diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx index a9169eb6580..1b45bfcd55d 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx @@ -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('[role="treeitem"]')) { - const row = item.querySelector('label')! - const cell = (n: number) => row.querySelector(`: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('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, - 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( - '[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') diff --git a/src/shared/agent-session/requests/structured-agent-session-latest-request.test.ts b/src/shared/agent-session/requests/structured-agent-session-latest-request.test.ts deleted file mode 100644 index 583413ba3de..00000000000 --- a/src/shared/agent-session/requests/structured-agent-session-latest-request.test.ts +++ /dev/null @@ -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 -): AgentJournalRenderItem { - return { - itemId: `codex:turn:${turnId}`, - revision: 1, - sequence, - observedAt: sequence, - body: { kind: 'turn', turnId, ...fields } - } -} - -function sent( - clientMessageId: string, - fields: Partial & Pick -): 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.' - ) - }) -}) diff --git a/src/shared/agent-status/process/opencode-headless-command.test.ts b/src/shared/agent-status/process/opencode-headless-command.test.ts index a89d92afb10..69753266697 100644 --- a/src/shared/agent-status/process/opencode-headless-command.test.ts +++ b/src/shared/agent-status/process/opencode-headless-command.test.ts @@ -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', diff --git a/src/shared/automation-execution-target.test.ts b/src/shared/automation-execution-target.test.ts index aed37067980..b57d2b44a93 100644 --- a/src/shared/automation-execution-target.test.ts +++ b/src/shared/automation-execution-target.test.ts @@ -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( diff --git a/src/shared/automation-host-filter.test.ts b/src/shared/automation-host-filter.test.ts deleted file mode 100644 index f6b2b74d202..00000000000 --- a/src/shared/automation-host-filter.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/shared/automation-legacy-list-partition.test.ts b/src/shared/automation-legacy-list-partition.test.ts deleted file mode 100644 index 635ff715633..00000000000 --- a/src/shared/automation-legacy-list-partition.test.ts +++ /dev/null @@ -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 { - 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 - }) - }) -}) diff --git a/src/shared/automation-list-response.test.ts b/src/shared/automation-list-response.test.ts index 1ab831194b2..3c9fc8a04fd 100644 --- a/src/shared/automation-list-response.test.ts +++ b/src/shared/automation-list-response.test.ts @@ -17,23 +17,6 @@ function response(overrides: Record = {}) { } 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) - }) }) diff --git a/src/shared/automation-list-scope.test.ts b/src/shared/automation-list-scope.test.ts index 4f72cd20400..a8d482af3d2 100644 --- a/src/shared/automation-list-scope.test.ts +++ b/src/shared/automation-list-scope.test.ts @@ -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 { @@ -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), - context({ workspaceHost: () => ({ kind: 'local' }) }) - ) - ).toEqual({ kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.malformed }) - }) }) diff --git a/src/shared/automation-owner-key.test.ts b/src/shared/automation-owner-key.test.ts index e81592f4385..60bacb62435 100644 --- a/src/shared/automation-owner-key.test.ts +++ b/src/shared/automation-owner-key.test.ts @@ -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', diff --git a/src/shared/automation-run-identity.test.ts b/src/shared/automation-run-identity.test.ts deleted file mode 100644 index 1738cba3fce..00000000000 --- a/src/shared/automation-run-identity.test.ts +++ /dev/null @@ -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 { - 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') - }) -}) diff --git a/src/shared/automation-run-retention.test.ts b/src/shared/automation-run-retention.test.ts index b4686ec0f33..1bacd9656b4 100644 --- a/src/shared/automation-run-retention.test.ts +++ b/src/shared/automation-run-retention.test.ts @@ -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) }) diff --git a/src/shared/automation-schedule-drift.test.ts b/src/shared/automation-schedule-drift.test.ts index cb96df9c3d4..28934d4e408 100644 --- a/src/shared/automation-schedule-drift.test.ts +++ b/src/shared/automation-schedule-drift.test.ts @@ -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() - }) }) diff --git a/src/shared/automation-schedules.test.ts b/src/shared/automation-schedules.test.ts index 2b10cb5875c..5ac5c615b45 100644 --- a/src/shared/automation-schedules.test.ts +++ b/src/shared/automation-schedules.test.ts @@ -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 - ) { - 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 *', diff --git a/src/shared/automation-usage-summary.test.ts b/src/shared/automation-usage-summary.test.ts deleted file mode 100644 index 2978d95b88f..00000000000 --- a/src/shared/automation-usage-summary.test.ts +++ /dev/null @@ -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 => ({ - 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) - }) -}) diff --git a/src/shared/base-ref-search-result.test.ts b/src/shared/base-ref-search-result.test.ts deleted file mode 100644 index 53783ec1e87..00000000000 --- a/src/shared/base-ref-search-result.test.ts +++ /dev/null @@ -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' - }) - }) -}) diff --git a/src/shared/binary-file-extensions.test.ts b/src/shared/binary-file-extensions.test.ts deleted file mode 100644 index 031e1c36318..00000000000 --- a/src/shared/binary-file-extensions.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/shared/bounded-map.test.ts b/src/shared/bounded-map.test.ts index dc1f5a91c1c..c70ebf85250 100644 --- a/src/shared/bounded-map.test.ts +++ b/src/shared/bounded-map.test.ts @@ -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({ maxEntries: 0 })).toThrow(RangeError) - expect(() => new BoundedMap({ maxEntries: 4, maxBytes: 10 })).toThrow(/sizeOf/) - }) - - it('evicts the least-recently-used entry past the count ceiling', () => { - const evicted: string[] = [] - const m = new BoundedMap({ 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({ 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({ - 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({ - 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({ - 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({ maxEntries: 2, maxBytes: Number.NaN, sizeOf }) - ).toThrow(RangeError) - expect(() => new BoundedMap({ maxEntries: 2, maxBytes: -1, sizeOf })).toThrow( - RangeError - ) - }) - it('a rejected oversized overwrite leaves the existing value untouched', () => { const m = new BoundedMap({ maxEntries: 10, @@ -161,38 +93,6 @@ describe('BoundedMap', () => { ]) }) - it('peek() reads without changing eviction order', () => { - const m = new BoundedMap({ 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({ 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({ - 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([ ['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({ maxEntries: 2, maxBytes: 1.5, sizeOf })).toThrow( - RangeError - ) - const m = new BoundedMap({ 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({ - 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({ 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({ - 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 = new BoundedMap({ - 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({ @@ -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({ maxEntries: 5, onEvict }) - m.set('a', 1) - m.delete('a') - m.set('b', 2) - m.clear() - expect(onEvict).not.toHaveBeenCalled() - }) }) diff --git a/src/shared/browser-annotation-viewport-lifecycle.test.ts b/src/shared/browser-annotation-viewport-lifecycle.test.ts deleted file mode 100644 index cc7fbe8a4d7..00000000000 --- a/src/shared/browser-annotation-viewport-lifecycle.test.ts +++ /dev/null @@ -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)[] = [] - -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) - }) -}) diff --git a/src/shared/browser-network-tunnel-protocol.test.ts b/src/shared/browser-network-tunnel-protocol.test.ts index 3338ca8d27d..f14fcd369ce 100644 --- a/src/shared/browser-network-tunnel-protocol.test.ts +++ b/src/shared/browser-network-tunnel-protocol.test.ts @@ -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(() =>