mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
test: remove 321 low-value main and shared tests (#39429)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -888,7 +888,6 @@
|
||||
1 test-timeout-needs-reason:src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts
|
||||
4 test-timeout-needs-reason:src/main/git/refs/maintenance/repo-ref-maintenance-real-git.test.ts
|
||||
1 test-timeout-needs-reason:src/main/git/worktrees/removal/worktree-removal-real-git.test.ts
|
||||
1 test-timeout-needs-reason:src/main/hermes/hook-service.test.ts
|
||||
1 test-timeout-needs-reason:src/main/ipc/filesystem/filesystem-quick-open-options.integration.test.ts
|
||||
1 test-timeout-needs-reason:src/main/ipc/preflight-runnable-local-cli.test.ts
|
||||
1 test-timeout-needs-reason:src/main/ipc/worktrees/git/worktree-git-status-attribution-real-git.test.ts
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1945,7 +1945,6 @@
|
||||
"src/main/emulator/serve-sim-helper-processes.test.ts": 59,
|
||||
"src/main/emulator/serve-sim-kill-contract.test.ts": 1284,
|
||||
"src/main/emulator/serve-sim-runtime-materializer.test.ts": 178,
|
||||
"src/main/emulator/simctl-simulator-devices.test.ts": 59,
|
||||
"src/main/ephemeral-vm/recipe-runner.test.ts": 460,
|
||||
"src/main/ephemeral-vm/runtime-service.test.ts": 870,
|
||||
"src/main/ephemeral-vm/runtime-ssh-cleanup.test.ts": 188,
|
||||
@@ -2226,7 +2225,6 @@
|
||||
"src/main/gitlab/client-mr-listing.test.ts": 558,
|
||||
"src/main/gitlab/client-mr-review-actions.test.ts": 482,
|
||||
"src/main/gitlab/client-work-items.test.ts": 492,
|
||||
"src/main/gitlab/client.test.ts": 534,
|
||||
"src/main/gitlab/gitlab-known-host-backoff.test.ts": 477,
|
||||
"src/main/gitlab/gitlab-known-host-probe.test.ts": 389,
|
||||
"src/main/gitlab/gitlab-known-host-retirement.test.ts": 509,
|
||||
@@ -2529,7 +2527,6 @@
|
||||
"src/main/ipc/skill-install-management-ipc-handlers.test.ts": 193,
|
||||
"src/main/ipc/skill-install-progress-ipc.test.ts": 48,
|
||||
"src/main/ipc/source-control/source-control-ai-linked-issue.test.ts": 60,
|
||||
"src/main/ipc/speech.test.ts": 60,
|
||||
"src/main/ipc/ssh/ssh-app-shutdown.test.ts": 4455,
|
||||
"src/main/ipc/ssh/ssh-connect-decision-cancellation.test.ts": 6414,
|
||||
"src/main/ipc/ssh/ssh-credential-cancel.test.ts": 4604,
|
||||
@@ -6293,7 +6290,6 @@
|
||||
"src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx": 692,
|
||||
"src/renderer/src/components/native-chat/NativeChatNewRowsMarker.test.tsx": 3019,
|
||||
"src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx": 3399,
|
||||
"src/renderer/src/components/native-chat/NativeChatPacedMarkdown.file-links.test.tsx": 2576,
|
||||
"src/renderer/src/components/native-chat/NativeChatPacedMarkdown.mermaid.test.tsx": 2702,
|
||||
"src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx": 2736,
|
||||
"src/renderer/src/components/native-chat/NativeChatPromptEditor.paragraph-selection.test.tsx": 652,
|
||||
@@ -6324,7 +6320,6 @@
|
||||
"src/renderer/src/components/native-chat/NativeChatStructuredSession.wsl-host-stopped.test.tsx": 3657,
|
||||
"src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx": 583,
|
||||
"src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx": 544,
|
||||
"src/renderer/src/components/native-chat/NativeChatToolFileTarget.test.tsx": 2047,
|
||||
"src/renderer/src/components/native-chat/NativeChatToolLine.test.tsx": 3544,
|
||||
"src/renderer/src/components/native-chat/NativeChatToolOutput.test.tsx": 410,
|
||||
"src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx": 3128,
|
||||
@@ -6364,7 +6359,6 @@
|
||||
"src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts": 6688,
|
||||
"src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx": 1409,
|
||||
"src/renderer/src/components/native-chat/native-chat-composer-ime-file-drop.test.tsx": 1326,
|
||||
"src/renderer/src/components/native-chat/native-chat-composer-mobile-hold.test.tsx": 899,
|
||||
"src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx": 1724,
|
||||
"src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx": 526,
|
||||
"src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx": 1166,
|
||||
@@ -9144,10 +9138,8 @@
|
||||
"src/shared/ndjson-retained-chunk-memory.test.ts": 327,
|
||||
"src/shared/nested-repo-telemetry-schema.test.ts": 196,
|
||||
"src/shared/nested-repo-telemetry.test.ts": 55,
|
||||
"src/shared/nested-worker-depth.test.ts": 45,
|
||||
"src/shared/network-proxy.test.ts": 44,
|
||||
"src/shared/network/manual-address.test.ts": 68,
|
||||
"src/shared/network/server-share-address.test.ts": 50,
|
||||
"src/shared/new-workspace-dialog-repo.test.ts": 50,
|
||||
"src/shared/new-workspace/smart-workspace-linear-intent.test.ts": 64,
|
||||
"src/shared/new-workspace/smart-workspace-url-source-results.test.ts": 67,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { selectAcpHostAccountHome } from '../acp/acp-launch-command'
|
||||
import type { AcpHostBackendSettings } from '../acp/acp-launch-specs'
|
||||
import { resolveAcpLaunchInvocation } from '../acp/acp-structured-launch-resolution'
|
||||
import { ANTIGRAVITY_LAUNCH_SPEC } from './antigravity-acp-launch-spec'
|
||||
|
||||
// agy's own sign-in, read from the host's keyring or token file.
|
||||
@@ -127,26 +126,3 @@ it.skipIf(process.platform === 'win32')(
|
||||
).resolves.toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('a chat launches on the backend its account home pins', async () => {
|
||||
const resolve = (path: string) =>
|
||||
resolveAcpLaunchInvocation(
|
||||
ANTIGRAVITY_LAUNCH_SPEC,
|
||||
{ variable: 'GEMINI_HOME', path },
|
||||
{
|
||||
resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }),
|
||||
resolveCommand: (command) => `/resolved/${command}`,
|
||||
dataDirectory: '/orca-data'
|
||||
}
|
||||
)
|
||||
const host = await resolve('/home/user/.gemini')
|
||||
expect(host.command).toBe('/resolved/agy')
|
||||
expect(host.spec.args({ fullAccess: false, pluginDir: null })).toEqual(['acp'])
|
||||
expect(host.env.GEMINI_HOME).toBeUndefined()
|
||||
expect(host.env.AGY_ACP_FORCE_FILE_STORAGE).toBeUndefined()
|
||||
|
||||
const managed = await resolve('/orca-data/antigravity-chat')
|
||||
expect(managed.spec.managedRuntime).toBeDefined()
|
||||
expect(managed.env.GEMINI_HOME).toBe('/orca-data/antigravity-chat')
|
||||
expect(managed.env.AGY_ACP_FORCE_FILE_STORAGE).toBe('1')
|
||||
})
|
||||
|
||||
@@ -46,30 +46,6 @@ function launch(runtime: ReturnType<typeof fakeRuntime>, reuseSession = false) {
|
||||
}
|
||||
|
||||
describe('headless automation agent launch', () => {
|
||||
it.each([
|
||||
['is refused', async () => false, "Aider started, but Orca could not send this run's prompt."],
|
||||
[
|
||||
'throws',
|
||||
async () => {
|
||||
throw new Error('writer broke')
|
||||
},
|
||||
'writer broke'
|
||||
]
|
||||
])(
|
||||
'opens the terminal and reports the prompt undelivered when delivery %s',
|
||||
async (_, deliver, error) => {
|
||||
const opened = await launch(fakeRuntime(deliver))
|
||||
|
||||
expect(opened).toMatchObject({
|
||||
terminalHandle: 'term-1',
|
||||
terminalSessionId: 'tab-1',
|
||||
terminalPaneKey: 'tab-1:leaf-1',
|
||||
terminalPtyId: 'pty-1'
|
||||
})
|
||||
await expect(opened.delivery).resolves.toEqual({ error })
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['resolves on a paired server', { hostId: 'runtime:env-1' }, null],
|
||||
['is gone', null, new Error('selector_not_found')]
|
||||
|
||||
@@ -68,44 +68,6 @@ const automation: Automation = {
|
||||
}
|
||||
|
||||
describe('headless automation workspace create args', () => {
|
||||
it('stamps automation provenance for serve-mode new-per-run workspaces', () => {
|
||||
const args = buildAutomationWorktreeCreateArgs({
|
||||
automation,
|
||||
run: {
|
||||
id: 'run-1',
|
||||
title: 'Nightly review run',
|
||||
scheduledFor: Date.UTC(2026, 0, 2, 3, 4, 5)
|
||||
},
|
||||
repo,
|
||||
createdAt: 123
|
||||
})
|
||||
|
||||
expect(args).toMatchObject({
|
||||
repoSelector: { selector: 'id:repo-1', hostId: 'ssh:ssh-target-1' },
|
||||
name: 'auto-nightly-review-run-20260102T0304',
|
||||
baseBranch: 'origin/main',
|
||||
setupDecision: 'skip',
|
||||
activate: false,
|
||||
createdWithAgent: 'codex',
|
||||
startupAgent: 'codex',
|
||||
startupPrompt: 'Review changes',
|
||||
telemetrySource: 'unknown',
|
||||
automationProvenance: {
|
||||
kind: 'created-by-automation',
|
||||
automationId: 'automation-1',
|
||||
automationNameSnapshot: 'Nightly review',
|
||||
automationRunId: 'run-1',
|
||||
automationRunTitleSnapshot: 'Nightly review run',
|
||||
createdAt: 123,
|
||||
executionTargetType: 'ssh',
|
||||
executionTargetId: 'ssh-target-1',
|
||||
projectId: 'project-1',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'ssh:ssh-target-1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('skips setup when the created branch differs from the content approved for the automation', async () => {
|
||||
const worktreePath = mkdtempSync(path.join(tmpdir(), 'orca-automation-setup-'))
|
||||
try {
|
||||
|
||||
@@ -89,22 +89,7 @@ describe('AutomationService zero-grace tick latency', () => {
|
||||
expect(await statusAt(lateMs)).toBe('dispatched')
|
||||
})
|
||||
|
||||
it('still skips a zero-grace occurrence that came due during a long sleep', async () => {
|
||||
expect(await statusAt(4 * 60 * 60 * 1000)).toBe('skipped_missed')
|
||||
})
|
||||
|
||||
it('skips once lateness exceeds the tick-latency tolerance', async () => {
|
||||
expect(await statusAt(2 * 60_000 + 1)).toBe('skipped_missed')
|
||||
})
|
||||
|
||||
it('dispatches after a restart that crosses the occurrence within tolerance', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = makeZeroGrace(store)
|
||||
await evaluateAt(store, DUE - 30_000)
|
||||
expect(store.listAutomationRuns(automation.id)).toHaveLength(0)
|
||||
await evaluateAt(store, DUE + 30_000)
|
||||
expect(store.listAutomationRuns(automation.id)[0]?.status).toBe('dispatched')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -158,27 +158,6 @@ describe('reconciling retained runs against a graph that has not published yet',
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('does not fail a serve-startup retained run whose host pane has not reattached', async () => {
|
||||
const store = await createStore()
|
||||
const automation = createAutomation(store)
|
||||
const retained = retainDispatchedRun(store, automation)
|
||||
const surface = createPaneSurface()
|
||||
const service = new AutomationService(store, {
|
||||
terminalLauncher: OPEN_LAUNCHER,
|
||||
terminalObserver: surface.observer
|
||||
})
|
||||
|
||||
service.start()
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(readRun(store, automation.id, retained.id).status).toBe('dispatched')
|
||||
|
||||
surface.mountPane()
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(surface.observedHandles).toEqual(['handle-1'])
|
||||
expect(readRun(store, automation.id, retained.id).status).toBe('dispatched')
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('still closes out a run the ready surface cannot find after the settle window', async () => {
|
||||
const store = await createStore()
|
||||
const automation = createAutomation(store)
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AutomationRun, AutomationRunStatus } from '../../shared/automations-types'
|
||||
import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue'
|
||||
import {
|
||||
createRunTerminalRetention,
|
||||
RUN_TERMINAL_GRACE_MS,
|
||||
RUN_TERMINALS_KEPT_PER_AUTOMATION
|
||||
} from './run-terminal-retention'
|
||||
import { createRunTerminalRetention, RUN_TERMINAL_GRACE_MS } from './run-terminal-retention'
|
||||
|
||||
function makeRun(
|
||||
id: string,
|
||||
@@ -87,19 +83,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('headless run terminal retention', () => {
|
||||
it('closes finished run terminals past the grace period, keeping the newest few viewable', async () => {
|
||||
const runs = [0, 1, 2, 3, 4, 5].map((hour) => makeRun(`r${hour}`, hour * 3_600_000))
|
||||
const h = harness(runs)
|
||||
|
||||
await h.retention.sweep()
|
||||
expect(h.closed).toEqual([])
|
||||
|
||||
vi.advanceTimersByTime(RUN_TERMINAL_GRACE_MS)
|
||||
await h.retention.sweep()
|
||||
expect(h.closed.toSorted()).toEqual(['tab-r0:1', 'tab-r1:1', 'tab-r2:1'])
|
||||
expect(6 - h.closed.length).toBe(RUN_TERMINALS_KEPT_PER_AUTOMATION)
|
||||
})
|
||||
|
||||
it('never closes a run that has not finished, however old', async () => {
|
||||
const runs = [
|
||||
makeRun('working', 0, 'dispatched'),
|
||||
@@ -142,18 +125,6 @@ describe('headless run terminal retention', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the newest few per automation, not across all of them', async () => {
|
||||
const runs = [
|
||||
...[0, 1, 2].map((n) => makeRun(`a${n}`, n, 'completed', 'a')),
|
||||
...[0, 1, 2].map((n) => makeRun(`b${n}`, n, 'completed', 'b'))
|
||||
]
|
||||
const h = harness(runs)
|
||||
await h.retention.sweep()
|
||||
vi.advanceTimersByTime(RUN_TERMINAL_GRACE_MS)
|
||||
await h.retention.sweep()
|
||||
expect(h.closed).toEqual([])
|
||||
})
|
||||
|
||||
it('drains every completed, unused run terminal for an update, ignoring keep and grace', async () => {
|
||||
const runs = [
|
||||
...[0, 1, 2, 3, 4].map((n) => makeRun(`r${n}`, n)),
|
||||
@@ -190,30 +161,6 @@ describe('headless run terminal retention', () => {
|
||||
expect(h.forgotten[0]).toMatchObject({ id: 'missing', status: 'dispatch_failed' })
|
||||
})
|
||||
|
||||
it('closes a still-dispatched run whose agent exited, leaving its shell idle', async () => {
|
||||
const runs = [
|
||||
makeRun('exited', 0, 'dispatched'),
|
||||
...[1, 2, 3].map((n) => makeRun(`done${n}`, n))
|
||||
]
|
||||
const h = harness(runs)
|
||||
h.idleShell.add('exited')
|
||||
await h.retention.sweep()
|
||||
vi.advanceTimersByTime(RUN_TERMINAL_GRACE_MS)
|
||||
await h.retention.sweep()
|
||||
expect(h.closed).toEqual(['tab-exited:1'])
|
||||
})
|
||||
|
||||
it('keeps the newest three live terminals, not counting ones already gone', async () => {
|
||||
const runs = [0, 1, 2, 3, 4].map((n) => makeRun(`r${n}`, n))
|
||||
const h = harness(runs)
|
||||
h.dead.add('r4')
|
||||
await h.retention.sweep()
|
||||
vi.advanceTimersByTime(RUN_TERMINAL_GRACE_MS)
|
||||
await h.retention.sweep()
|
||||
expect(h.closed).toEqual(['tab-r0:1'])
|
||||
expect(h.forgotten.map((run) => run.id).toSorted()).toEqual(['r0', 'r4'])
|
||||
})
|
||||
|
||||
it('drains idle failed and exited runs for an update, never a live agent', async () => {
|
||||
const runs = [
|
||||
makeRun('missing', 0, 'dispatch_failed'),
|
||||
@@ -329,27 +276,6 @@ describe('headless run terminal retention', () => {
|
||||
expect(h.forgotten.map((run) => run.id)).not.toContain('r0')
|
||||
})
|
||||
|
||||
it('closes a failed run in its own workspace, and never sleeps or closes a used one', async () => {
|
||||
const runs = [
|
||||
makeRun('failed', 0, 'dispatch_failed'),
|
||||
makeRun('typed', 1),
|
||||
...[2, 3, 4].map((n) => makeRun(`done${n}`, n))
|
||||
]
|
||||
const h = harness(runs)
|
||||
h.ownWorkspace.add('failed')
|
||||
h.ownWorkspace.add('typed')
|
||||
h.idleShell.add('failed')
|
||||
h.use.set('typed', 'used')
|
||||
await h.retention.drain()
|
||||
expect(h.slept).toEqual([])
|
||||
expect(h.closed.toSorted()).toEqual([
|
||||
'tab-done2:1',
|
||||
'tab-done3:1',
|
||||
'tab-done4:1',
|
||||
'tab-failed:1'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a run in its own workspace whose sleep declines or fails, retrying each sweep', async () => {
|
||||
const runs = [0, 1, 2, 3].map((n) => makeRun(`r${n}`, n))
|
||||
const h = harness(runs)
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createServer, type Server, type Socket } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { DaemonClient } from '../daemon-client'
|
||||
import type { DaemonPendingRequests } from '../daemon-client-pending-requests'
|
||||
import {
|
||||
encodeNdjson,
|
||||
NDJSON_MAX_LINE_BYTES,
|
||||
NdjsonLineTooLongError
|
||||
} from '../../../../../shared/main-process-ndjson-framer'
|
||||
import { encodeNdjson } from '../../../../../shared/main-process-ndjson-framer'
|
||||
import { DaemonConnectionLostError } from '../../../protocol/daemon-errors'
|
||||
import type { HelloMessage } from '../../../protocol/daemon-hello-protocol'
|
||||
import type { DaemonRequest } from '../../../protocol/daemon-rpc-contract'
|
||||
@@ -147,23 +141,6 @@ describe('DaemonClient', () => {
|
||||
}
|
||||
|
||||
describe('connect', () => {
|
||||
it('captures one matching endpoint identity from both authenticated sockets', async () => {
|
||||
const identity = {
|
||||
pid: 123,
|
||||
startedAtMs: 456,
|
||||
launchNonce: 'launch-a',
|
||||
entryPath: '/Applications/Orca.app/Contents/Resources/daemon-entry.js',
|
||||
appVersion: '1.2.3',
|
||||
spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca'
|
||||
}
|
||||
await startMockDaemon({ helloIdentity: () => identity })
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
|
||||
expect(client.getDaemonIdentity()).toEqual(identity)
|
||||
})
|
||||
|
||||
it('rejects a v24 daemon that omits endpoint identity', async () => {
|
||||
await startMockDaemon({ omitHelloIdentity: true })
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
@@ -196,30 +173,6 @@ describe('DaemonClient', () => {
|
||||
expect(client.getDaemonIdentity()).toBeNull()
|
||||
})
|
||||
|
||||
it('removes socket startup listeners after connecting', async () => {
|
||||
await startMockDaemon()
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
|
||||
const connectedClient = client as unknown as {
|
||||
controlSocket: Socket | null
|
||||
streamSocket: Socket | null
|
||||
}
|
||||
for (const socket of [connectedClient.controlSocket, connectedClient.streamSocket]) {
|
||||
expect(socket?.listenerCount('connect')).toBe(0)
|
||||
// The post-handshake disconnect handler still needs one error listener.
|
||||
expect(socket?.listenerCount('error')).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects on version mismatch', async () => {
|
||||
await startMockDaemon({ rejectVersion: true })
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await expect(client.ensureConnected()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('times out when the daemon never answers hello', async () => {
|
||||
let resolveHello: () => void = () => {}
|
||||
const helloReceived = new Promise<void>((resolve) => {
|
||||
@@ -276,127 +229,9 @@ describe('DaemonClient', () => {
|
||||
expect(disconnected.controlSocket).toBeNull()
|
||||
expect(disconnected.streamSocket).toBeNull()
|
||||
})
|
||||
|
||||
it('removes hello startup listeners after timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
const write = vi.fn(() => true)
|
||||
const destroy = vi.fn()
|
||||
const socket = new EventEmitter() as Socket
|
||||
socket.write = write as unknown as Socket['write']
|
||||
socket.destroy = destroy as unknown as Socket['destroy']
|
||||
const sendHello = (
|
||||
client as unknown as {
|
||||
sendHello(
|
||||
socket: Socket,
|
||||
token: string,
|
||||
role: 'control' | 'stream',
|
||||
timeoutMs: number
|
||||
): Promise<void>
|
||||
}
|
||||
).sendHello.bind(client)
|
||||
|
||||
const promise = sendHello(socket, 'test-token-123', 'control', 5000)
|
||||
const rejection = expect(promise).rejects.toThrow('Hello response timed out')
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
await rejection
|
||||
expect(write).toHaveBeenCalledOnce()
|
||||
expect(destroy).toHaveBeenCalledOnce()
|
||||
expect(socket.listenerCount('data')).toBe(0)
|
||||
expect(socket.listenerCount('error')).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects when the daemon closes before hello completes', async () => {
|
||||
await startMockDaemon({ closeOnHello: true })
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await expect(client.ensureConnected()).rejects.toThrow(
|
||||
'Connection closed before hello response'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when the daemon closes immediately after connect', async () => {
|
||||
await startMockDaemon({ closeOnConnect: true })
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await expect(client.ensureConnected()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('fails a retired endpoint at the connect, not at the token read', async () => {
|
||||
rmSync(tokenPath)
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
|
||||
const error = (await client
|
||||
.ensureConnected()
|
||||
.catch((err: unknown) => err)) as NodeJS.ErrnoException
|
||||
|
||||
expect(error.syscall).toBe('connect')
|
||||
expect(['ENOENT', 'ECONNREFUSED']).toContain(error.code)
|
||||
})
|
||||
|
||||
it('still attempts the handshake when the token is gone but a daemon is listening', async () => {
|
||||
const hellos: HelloMessage[] = []
|
||||
await startMockDaemon({ onHello: (msg) => hellos.push(msg) })
|
||||
rmSync(tokenPath)
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
|
||||
expect(hellos.length).toBeGreaterThan(0)
|
||||
expect(hellos[0]?.token).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RPC', () => {
|
||||
it('rejects an oversized request before installing a timer or writing', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const internals = client as unknown as {
|
||||
controlSocket: Socket
|
||||
pendingRequests: DaemonPendingRequests
|
||||
}
|
||||
const writeSpy = vi.spyOn(internals.controlSocket, 'write')
|
||||
const timerSpy = vi.spyOn(globalThis, 'setTimeout')
|
||||
try {
|
||||
await expect(
|
||||
client.request('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })
|
||||
).rejects.toBeInstanceOf(NdjsonLineTooLongError)
|
||||
|
||||
expect(timerSpy).not.toHaveBeenCalled()
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(internals.pendingRequests.size).toBe(0)
|
||||
} finally {
|
||||
timerSpy.mockRestore()
|
||||
writeSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends request and receives response', async () => {
|
||||
await startMockDaemon({
|
||||
onControlMessage: (msg) => {
|
||||
const req = msg as { id: string; type: string }
|
||||
if (req.type === 'listSessions') {
|
||||
return encodeNdjson({
|
||||
id: req.id,
|
||||
ok: true,
|
||||
payload: { sessions: [] }
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
|
||||
const result = await client.request('listSessions', undefined)
|
||||
expect(result).toEqual({ sessions: [] })
|
||||
})
|
||||
|
||||
it('survives a cancelCreateOrAttach the daemon refuses', async () => {
|
||||
// Old daemons reject cancellation; that refusal must not disconnect sibling sessions.
|
||||
const received: string[] = []
|
||||
@@ -454,47 +289,6 @@ describe('DaemonClient', () => {
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('rejects on error response', async () => {
|
||||
await startMockDaemon({
|
||||
onControlMessage: (msg) => {
|
||||
const req = msg as { id: string; type: string }
|
||||
return encodeNdjson({
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: 'Something went wrong'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
|
||||
await expect(client.request('listSessions', undefined)).rejects.toThrow(
|
||||
'Something went wrong'
|
||||
)
|
||||
})
|
||||
|
||||
it('adds recovery hints to node-pty daemon diagnostics', async () => {
|
||||
await startMockDaemon({
|
||||
onControlMessage: (msg) => {
|
||||
const req = msg as { id: string; type: string }
|
||||
return encodeNdjson({
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error:
|
||||
"node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/spawn-helper'"
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
|
||||
await expect(client.request('listSessions', undefined)).rejects.toThrow(
|
||||
"Daemon's node-pty install is gone (worktree deleted?). Restart Orca. node-pty: posix_spawn failed: ENOENT"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
@@ -541,123 +335,7 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('removes socket listeners when disconnecting', async () => {
|
||||
await startMockDaemon()
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
|
||||
const connectedClient = client as unknown as {
|
||||
controlSocket: Socket | null
|
||||
streamSocket: Socket | null
|
||||
}
|
||||
const sockets = [connectedClient.controlSocket, connectedClient.streamSocket]
|
||||
for (const socket of sockets) {
|
||||
expect(socket?.listenerCount('data')).toBe(1)
|
||||
expect(socket?.listenerCount('close')).toBe(1)
|
||||
expect(socket?.listenerCount('error')).toBe(1)
|
||||
}
|
||||
|
||||
client.disconnect()
|
||||
|
||||
for (const socket of sockets) {
|
||||
expect(socket?.listenerCount('data')).toBe(0)
|
||||
expect(socket?.listenerCount('close')).toBe(0)
|
||||
expect(socket?.listenerCount('error')).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('emits disconnected when server destroys sockets', async () => {
|
||||
const serverSockets: Socket[] = []
|
||||
await startMockDaemon()
|
||||
server.on('connection', (socket) => serverSockets.push(socket))
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
const disconnected = vi.fn()
|
||||
client.onDisconnected(disconnected)
|
||||
await client.ensureConnected()
|
||||
|
||||
await waitFor(() => serverSockets.length >= 2)
|
||||
|
||||
for (const socket of serverSockets) {
|
||||
socket.destroy()
|
||||
}
|
||||
|
||||
await waitFor(() => disconnected.mock.calls.length > 0, 3000)
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
it('disconnect() can be called safely when not connected', () => {
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
expect(() => client.disconnect()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('notify (fire-and-forget)', () => {
|
||||
it('sends request with notify_ prefix without expecting response', async () => {
|
||||
const received: unknown[] = []
|
||||
await startMockDaemon({
|
||||
onControlMessage: (msg) => {
|
||||
received.push(msg)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
|
||||
const delivered = client.notify('write', { sessionId: 'session-1', data: 'hello' })
|
||||
expect(delivered).toBe(true)
|
||||
|
||||
await waitFor(() => received.length > 0)
|
||||
const msg = received[0] as { id: string; type: string }
|
||||
expect(msg.id).toMatch(/^notify_/)
|
||||
expect(msg.type).toBe('write')
|
||||
})
|
||||
|
||||
it('reports a dropped delivery when not connected', () => {
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
expect(client.notify('write', { sessionId: 'session-1', data: 'hello' })).toBe(false)
|
||||
})
|
||||
|
||||
it('reports a dropped delivery for an oversized payload without writing', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const internals = client as unknown as { controlSocket: Socket }
|
||||
const writeSpy = vi.spyOn(internals.controlSocket, 'write')
|
||||
|
||||
expect(client.notify('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })).toBe(false)
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an oversized settled notification without disconnecting', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const internals = client as unknown as { controlSocket: Socket }
|
||||
const writeSpy = vi.spyOn(internals.controlSocket, 'write')
|
||||
|
||||
await expect(
|
||||
client.notifyWithSettlement('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })
|
||||
).resolves.toEqual({ outcome: 'refused', reason: 'encode_failed' })
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(client.isConnected()).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a dropped delivery when the socket write throws', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
await client.ensureConnected()
|
||||
const internals = client as unknown as { controlSocket: Socket }
|
||||
vi.spyOn(internals.controlSocket, 'write').mockImplementation(() => {
|
||||
throw new Error('EPIPE')
|
||||
})
|
||||
|
||||
expect(client.notify('write', { sessionId: 'session-1', data: 'hello' })).toBe(false)
|
||||
})
|
||||
|
||||
it('reports an asynchronous socket write failure at settlement', async () => {
|
||||
await startMockDaemon()
|
||||
client = new DaemonClient({ socketPath, tokenPath })
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createBinaryStreamFrameReader,
|
||||
encodeBinaryStreamDataFrame,
|
||||
encodeBinaryStreamEventFrame
|
||||
encodeBinaryStreamDataFrame
|
||||
} from '../daemon-stream-binary-framing'
|
||||
import { createNdjsonParser } from '../../../../../shared/main-process-ndjson-framer'
|
||||
import { streamFramesFor, type StreamWriteChunk } from '../daemon-stream-data-split'
|
||||
import { streamFramesFor } from '../daemon-stream-data-split'
|
||||
import type { DaemonEvent } from '../../../protocol/daemon-stream-events'
|
||||
|
||||
function isDaemonEvent(record: unknown): record is DaemonEvent {
|
||||
return (
|
||||
typeof record === 'object' && record !== null && 'type' in record && record.type === 'event'
|
||||
)
|
||||
}
|
||||
|
||||
const TERMINAL_TEXT = '\x1b[38;5;208m┌─ build ─┐\x1b[0m \u{1F680} ok\r\n"quoted" \\ tab\t'
|
||||
|
||||
function readAll(chunks: Buffer[], splitEvery?: number): { events: DaemonEvent[]; fatal: Error[] } {
|
||||
const events: DaemonEvent[] = []
|
||||
const fatal: Error[] = []
|
||||
@@ -31,53 +21,7 @@ function readAll(chunks: Buffer[], splitEvery?: number): { events: DaemonEvent[]
|
||||
return { events, fatal }
|
||||
}
|
||||
|
||||
function binaryEvents(write: (stream: { write(chunk: StreamWriteChunk): void }) => void) {
|
||||
const frames: Buffer[] = []
|
||||
write({ write: (chunk) => frames.push(Buffer.from(chunk)) })
|
||||
return readAll(frames).events
|
||||
}
|
||||
|
||||
function ndjsonEvents(write: (stream: { write(chunk: StreamWriteChunk): void }) => void) {
|
||||
const events: DaemonEvent[] = []
|
||||
const parser = createNdjsonParser(
|
||||
(record) => {
|
||||
if (isDaemonEvent(record)) {
|
||||
events.push(record)
|
||||
}
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
write({ write: (chunk) => parser.feed(String(chunk)) })
|
||||
return events
|
||||
}
|
||||
|
||||
describe('binary stream framing', () => {
|
||||
it('round-trips data and event frames across any socket chunking', () => {
|
||||
const frames = [
|
||||
encodeBinaryStreamDataFrame('session-1', TERMINAL_TEXT, { seq: 42, rawLength: 7 }),
|
||||
encodeBinaryStreamEventFrame({
|
||||
type: 'event',
|
||||
event: 'exit',
|
||||
sessionId: 'session-1',
|
||||
payload: { code: 0 }
|
||||
}),
|
||||
encodeBinaryStreamDataFrame('session-2', '')
|
||||
]
|
||||
const expected = [
|
||||
{
|
||||
type: 'event',
|
||||
event: 'data',
|
||||
sessionId: 'session-1',
|
||||
payload: { seq: 42, rawLength: 7, data: TERMINAL_TEXT }
|
||||
},
|
||||
{ type: 'event', event: 'exit', sessionId: 'session-1', payload: { code: 0 } },
|
||||
{ type: 'event', event: 'data', sessionId: 'session-2', payload: { data: '' } }
|
||||
]
|
||||
for (const splitEvery of [undefined, 1, 3, 7]) {
|
||||
expect(readAll(frames, splitEvery)).toEqual({ events: expected, fatal: [] })
|
||||
}
|
||||
})
|
||||
|
||||
it('drops an unparsable record but keeps reading the frames after it', () => {
|
||||
const broken = Buffer.from([1, 0, 0, 0, 3, 0x7b, 0x7b, 0x7b])
|
||||
const next = encodeBinaryStreamDataFrame('session-1', 'after')
|
||||
@@ -102,64 +46,6 @@ describe('binary stream framing', () => {
|
||||
expect(oversized).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['plain', undefined, undefined, false],
|
||||
['seq and raw length', 12_000, 90_000, false],
|
||||
['zero raw length', 0, 7, false],
|
||||
['transformed', 3, 11, true]
|
||||
] as const)(
|
||||
'follows the NDJSON per-chunk metadata rules for %s data',
|
||||
(_label, raw, seq, transformed) => {
|
||||
const data = TERMINAL_TEXT.repeat(400)
|
||||
const rawLength = raw ?? data.length
|
||||
const write =
|
||||
(framing: 'ndjson' | 'binary-v1') =>
|
||||
(stream: { write(chunk: StreamWriteChunk): void }): void =>
|
||||
streamFramesFor(framing).data(
|
||||
stream,
|
||||
'session-1',
|
||||
data,
|
||||
4096,
|
||||
rawLength,
|
||||
seq,
|
||||
transformed
|
||||
)
|
||||
// Chunk boundaries differ by design (byte vs JSON budgets); the metadata rules must not.
|
||||
const describeChunks = (events: DaemonEvent[]) => {
|
||||
let consumed = 0
|
||||
return events.map((event) => {
|
||||
if (event.event !== 'data') {
|
||||
throw new Error('expected data events only')
|
||||
}
|
||||
const { data: chunk, ...meta } = event.payload
|
||||
consumed += chunk.length
|
||||
return {
|
||||
meta,
|
||||
expected: transformed
|
||||
? { seq, rawLength, sequenceChars: rawLength, transformed: true }
|
||||
: {
|
||||
...(seq === undefined ? {} : { seq: seq - (data.length - consumed) }),
|
||||
...(raw === undefined && seq === undefined
|
||||
? {}
|
||||
: {
|
||||
rawLength: raw === 0 ? 0 : chunk.length,
|
||||
sequenceChars: raw === 0 ? 0 : chunk.length
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for (const events of [binaryEvents(write('binary-v1')), ndjsonEvents(write('ndjson'))]) {
|
||||
expect(
|
||||
events.map((event) => (event.event === 'data' ? event.payload.data : '')).join('')
|
||||
).toBe(data)
|
||||
for (const { meta, expected } of describeChunks(events)) {
|
||||
expect(meta).toEqual(expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps every frame within the frame budget', () => {
|
||||
const frames: Buffer[] = []
|
||||
streamFramesFor('binary-v1').data(
|
||||
|
||||
@@ -22,12 +22,6 @@ function write(
|
||||
}
|
||||
|
||||
describe('writeStreamDataEvents wire parity', () => {
|
||||
it('keeps JSON escaping, Unicode and newline framing byte-for-byte', () => {
|
||||
expect(write('"\\\n\t\u0000é中🐙', 4096)).toEqual([
|
||||
'{"type":"event","event":"data","sessionId":"session-1","payload":{"data":"\\\"\\\\\\n\\t\\u0000é中🐙"}}\n'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps split frames within the byte cap and preserves code points and sequence spans', () => {
|
||||
const data = '🐙é中\x1b[0m"\\\n'.repeat(100)
|
||||
for (const seq of [undefined, 9000]) {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { readAndroidDeviceSystemMetadata } from './android-device-system-metadata'
|
||||
|
||||
let directory: string | undefined
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
if (directory) {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
it('reads the API of a stopped AVD from its configured custom data directory', async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-avd-metadata-'))
|
||||
const avdHome = join(directory, 'avds')
|
||||
const data = join(directory, 'custom-data')
|
||||
await mkdir(avdHome)
|
||||
await mkdir(data)
|
||||
await writeFile(join(avdHome, 'Pixel.ini'), `path=${data}\n`)
|
||||
await writeFile(
|
||||
join(data, 'config.ini'),
|
||||
'image.sysdir.1=system-images/android-36/google_apis/arm64-v8a/\n'
|
||||
)
|
||||
vi.stubEnv('ANDROID_AVD_HOME', avdHome)
|
||||
const runner = vi.fn()
|
||||
expect(
|
||||
await readAndroidDeviceSystemMetadata(
|
||||
{
|
||||
backend: 'android',
|
||||
id: 'Pixel',
|
||||
name: 'Pixel',
|
||||
detail: 'avd',
|
||||
state: 'shutdown',
|
||||
isAvailable: true
|
||||
},
|
||||
runner,
|
||||
{ sdkRoot: '/sdk', adb: '/sdk/adb', emulator: '/sdk/emulator' }
|
||||
)
|
||||
).toEqual({ apiLevel: '36' })
|
||||
expect(runner).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveEmulatorOpeningPreference,
|
||||
rememberEmulatorDevice
|
||||
} from './emulator-opening-preference'
|
||||
import type { EmulatorDevice } from './backends/emulator-backend'
|
||||
const devices: EmulatorDevice[] = [
|
||||
{ backend: 'ios', id: 'iphone', name: 'iPhone', state: 'booted', isAvailable: true },
|
||||
{
|
||||
backend: 'android',
|
||||
id: 'emulator-5558',
|
||||
name: 'Pixel9_API36',
|
||||
detail: 'emulator',
|
||||
state: 'booted',
|
||||
isAvailable: true
|
||||
}
|
||||
]
|
||||
describe('emulator opening preferences', () => {
|
||||
it('asks on first use instead of choosing a running iPhone', () => {
|
||||
expect(resolveEmulatorOpeningPreference({}, devices)).toEqual({
|
||||
selectionRequired: 'first-use'
|
||||
})
|
||||
})
|
||||
it('preserves a legacy fixed device, but respects an explicit ask or last-used mode', () => {
|
||||
const settings = {
|
||||
mobileEmulatorDefaultDeviceUdid: 'iphone',
|
||||
mobileEmulatorRecentDevices: rememberEmulatorDevice(devices[1], [])
|
||||
}
|
||||
expect(resolveEmulatorOpeningPreference(settings, devices)).toEqual({ device: 'iphone' })
|
||||
expect(
|
||||
resolveEmulatorOpeningPreference({ ...settings, mobileEmulatorOpeningMode: 'ask' }, devices)
|
||||
).toEqual({ selectionRequired: 'ask' })
|
||||
expect(
|
||||
resolveEmulatorOpeningPreference(
|
||||
{ ...settings, mobileEmulatorOpeningMode: 'last-used' },
|
||||
devices
|
||||
)
|
||||
).toEqual({ device: 'emulator-5558' })
|
||||
})
|
||||
it('does not replace a missing or unavailable preference with another platform', () => {
|
||||
expect(
|
||||
resolveEmulatorOpeningPreference({ mobileEmulatorDefaultDeviceUdid: 'removed' }, devices)
|
||||
).toEqual({ selectionRequired: 'missing' })
|
||||
expect(
|
||||
resolveEmulatorOpeningPreference({ mobileEmulatorDefaultDeviceUdid: 'iphone' }, [
|
||||
{ ...devices[0], isAvailable: false }
|
||||
])
|
||||
).toEqual({ selectionRequired: 'unavailable' })
|
||||
})
|
||||
it('keeps physical serials distinct from AVD names and bounds recent preferences', () => {
|
||||
const physical: EmulatorDevice = {
|
||||
...devices[1],
|
||||
id: 'USB-123',
|
||||
name: 'Pixel9_API36',
|
||||
detail: 'device'
|
||||
}
|
||||
const recent = rememberEmulatorDevice(physical, rememberEmulatorDevice(devices[1], []))
|
||||
expect(recent.map((item) => item.id)).toEqual(['USB-123', 'Pixel9_API36'])
|
||||
expect(
|
||||
resolveEmulatorOpeningPreference({ mobileEmulatorRecentDevices: recent }, devices)
|
||||
).toEqual({ selectionRequired: 'missing' })
|
||||
let history = recent
|
||||
for (let i = 0; i < 25; i++) {
|
||||
history = rememberEmulatorDevice({ ...physical, id: `USB-${i}` }, history)
|
||||
}
|
||||
expect(history).toHaveLength(20)
|
||||
})
|
||||
})
|
||||
@@ -1,188 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as Os from 'node:os'
|
||||
|
||||
const { execFileMock, platformMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
platformMock: vi.fn(() => 'darwin')
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: execFileMock
|
||||
}))
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof Os>()
|
||||
return {
|
||||
...actual,
|
||||
platform: platformMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./serve-sim-execution', () => ({
|
||||
execServeSimCommand: vi.fn()
|
||||
}))
|
||||
|
||||
import {
|
||||
listSimulatorDevices,
|
||||
resolveSimulatorUdid,
|
||||
shutdownSimulatorDevice
|
||||
} from './simctl-simulator-devices'
|
||||
|
||||
describe('listSimulatorDevices', () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
platformMock.mockReset()
|
||||
platformMock.mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
it('keeps lastBootedAt as usage evidence only when lastUsedAt is absent', async () => {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify({
|
||||
devices: {
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [
|
||||
{
|
||||
udid: 'booted-before',
|
||||
name: 'iPhone',
|
||||
state: 'Shutdown',
|
||||
lastBootedAt: '2026-10-29T10:00:00Z'
|
||||
},
|
||||
{
|
||||
udid: 'used-after',
|
||||
name: 'iPhone',
|
||||
state: 'Shutdown',
|
||||
lastBootedAt: '2026-10-29T10:00:00Z',
|
||||
lastUsedAt: '2026-10-30T11:00:00Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
''
|
||||
)
|
||||
})
|
||||
const devices = await listSimulatorDevices()
|
||||
expect(devices.map((device) => device.lastUsedAt)).toEqual([
|
||||
'2026-10-29T10:00:00Z',
|
||||
'2026-10-30T11:00:00Z'
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes missing simctl errors for UI and CLI callers', async () => {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(
|
||||
Object.assign(new Error('Command failed: xcrun simctl list devices -j'), { code: 72 }),
|
||||
'',
|
||||
'xcrun: error: unable to find utility "simctl", not a developer tool or in PATH'
|
||||
)
|
||||
})
|
||||
|
||||
const error = await listSimulatorDevices().catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: 'emulator_simctl_unavailable',
|
||||
message: expect.stringContaining('Xcode Simulator tools are unavailable')
|
||||
})
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).not.toContain('Command failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSimulatorUdid', () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
platformMock.mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
it('prefers an exact name over an earlier device whose name only contains it', async () => {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify({
|
||||
devices: {
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-27-0': [
|
||||
{
|
||||
name: 'iPhone 17e',
|
||||
udid: '645A2204-ED7C-4253-B298-2749820B1B7E',
|
||||
state: 'Shutdown'
|
||||
},
|
||||
{ name: 'iPhone 17', udid: '1BD4732B-EC1A-4074-8C70-6AC369607185', state: 'Shutdown' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
''
|
||||
)
|
||||
})
|
||||
const serveSim = { command: 'serve-sim', baseArgs: [], usesElectronAsNode: false }
|
||||
|
||||
await expect(resolveSimulatorUdid('iPhone 17', serveSim)).resolves.toBe(
|
||||
'1BD4732B-EC1A-4074-8C70-6AC369607185'
|
||||
)
|
||||
await expect(resolveSimulatorUdid('iPHONE 17', serveSim)).resolves.toBe(
|
||||
'1BD4732B-EC1A-4074-8C70-6AC369607185'
|
||||
)
|
||||
await expect(resolveSimulatorUdid('17e', serveSim)).resolves.toBe(
|
||||
'645A2204-ED7C-4253-B298-2749820B1B7E'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shutdownSimulatorDevice', () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
platformMock.mockReset()
|
||||
platformMock.mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
it('treats an already-shut-down device as success', async () => {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(
|
||||
Object.assign(
|
||||
new Error('Command failed: xcrun simctl shutdown AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'),
|
||||
{ code: 164 }
|
||||
),
|
||||
'',
|
||||
'Unable to shutdown device in current state: Shutdown'
|
||||
)
|
||||
})
|
||||
|
||||
await expect(
|
||||
shutdownSimulatorDevice('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE')
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('propagates real shutdown failures instead of swallowing them', async () => {
|
||||
// Command text always contains shutdown; only the actual device state proves it is already off.
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(
|
||||
Object.assign(
|
||||
new Error('Command failed: xcrun simctl shutdown AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'),
|
||||
{ killed: true, signal: 'SIGTERM' }
|
||||
),
|
||||
'',
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
await expect(
|
||||
shutdownSimulatorDevice('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE')
|
||||
).rejects.toMatchObject({ code: 'emulator_error' })
|
||||
})
|
||||
|
||||
it('rejects current-state failures that are not already shut down', async () => {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(
|
||||
Object.assign(
|
||||
new Error('Command failed: xcrun simctl shutdown AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'),
|
||||
{ code: 164 }
|
||||
),
|
||||
'',
|
||||
'Unable to shutdown device in current state: Booting'
|
||||
)
|
||||
})
|
||||
|
||||
await expect(
|
||||
shutdownSimulatorDevice('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE')
|
||||
).rejects.toMatchObject({ code: 'emulator_error' })
|
||||
})
|
||||
})
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
resetSshUserDisconnectsForTests
|
||||
} from '../ssh/ssh-user-disconnect'
|
||||
import { getRuntimeOwnedSshTargetId } from '../ssh/connection/ssh-connection-store'
|
||||
import { forgetRecipeVmWakePairingsForTests, recipeVmWakePairing } from './recipe-vm-wake-pairings'
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recipe context reader is mocked, so the resume never reads this store.
|
||||
const STORE = {} as Store
|
||||
@@ -67,15 +66,6 @@ describe("waking an SSH-connected recipe VM and its host's Disconnect", () => {
|
||||
})
|
||||
|
||||
// The reconnect can move the tunnel and re-pair the server; the open that waited expects that.
|
||||
it('reconnects the host when nobody disconnected it, recording the pairing it left', async () => {
|
||||
forgetRecipeVmWakePairingsForTests()
|
||||
await resumeEphemeralVmWorkspace(STORE, 'workspace-1', {
|
||||
sshHeld: false,
|
||||
sshUserDisconnects: 0
|
||||
})
|
||||
expect(mocks.connect).toHaveBeenCalledTimes(1)
|
||||
expect(recipeVmWakePairing('env-vm')).toBe(9)
|
||||
})
|
||||
|
||||
it('leaves a Disconnect made during the wake in place, with the VM up', async () => {
|
||||
gates.resuming = Promise.withResolvers<void>()
|
||||
@@ -117,12 +107,4 @@ describe("waking an SSH-connected recipe VM and its host's Disconnect", () => {
|
||||
|
||||
// Suspend tore down the VM's SSH transport, so the wake reconnects it even when the recipe had
|
||||
// no resume step to run.
|
||||
it('reconnects the host of a VM whose recipe resume was skipped', async () => {
|
||||
gates.skipped = true
|
||||
await resumeEphemeralVmWorkspace(STORE, 'workspace-1', {
|
||||
sshHeld: false,
|
||||
sshUserDisconnects: 0
|
||||
})
|
||||
expect(mocks.connect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,18 +9,11 @@ vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
import {
|
||||
getGiteaAuthStatus,
|
||||
getGiteaPullRequestForBranch,
|
||||
getGiteaPullRequestForBranchOrThrow,
|
||||
normalizeGiteaApiBaseUrl
|
||||
} from './client'
|
||||
import { getGiteaPullRequestForBranch, getGiteaPullRequestForBranchOrThrow } from './client'
|
||||
import { _resetGiteaRepoRefCache } from './repository-ref'
|
||||
import {
|
||||
_getGiteaPullRequestScanCacheSize,
|
||||
_resetGiteaPullRequestScanCache,
|
||||
scanGiteaPullRequests
|
||||
_resetGiteaPullRequestScanCache
|
||||
} from './pull-request-scan-cache'
|
||||
import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch'
|
||||
|
||||
@@ -70,50 +63,6 @@ describe('Gitea client', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('hides a stale closed PR whose source branch is the repo default branch (#9171)', async () => {
|
||||
primeGitExecWithDefaultBranch()
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.pathname.endsWith('/status')) {
|
||||
return Response.json({ state: 'success' })
|
||||
}
|
||||
return Response.json([{ ...giteaPr(7, 'main'), state: 'closed' }])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(getGiteaPullRequestForBranch('/repo', 'refs/heads/main')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('hides a stale merged PR on the default branch but keeps an open one', async () => {
|
||||
primeGitExecWithDefaultBranch()
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.pathname.endsWith('/status')) {
|
||||
return Response.json({ state: 'success' })
|
||||
}
|
||||
return Response.json([{ ...giteaPr(9, 'main'), merged: true }])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(getGiteaPullRequestForBranch('/repo', 'refs/heads/main')).resolves.toBeNull()
|
||||
|
||||
_resetGiteaPullRequestScanCache()
|
||||
__resetRepoDefaultBranchCacheForTests()
|
||||
const openFetchMock = vi.fn(async (url: string) => {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.pathname.endsWith('/status')) {
|
||||
return Response.json({ state: 'success' })
|
||||
}
|
||||
return Response.json([giteaPr(10, 'main')])
|
||||
})
|
||||
vi.stubGlobal('fetch', openFetchMock)
|
||||
|
||||
await expect(getGiteaPullRequestForBranch('/repo', 'refs/heads/main')).resolves.toMatchObject({
|
||||
number: 10,
|
||||
state: 'open'
|
||||
})
|
||||
})
|
||||
|
||||
it('discards a closed default-branch shadow and refetches the linked PR via the fallback (#9171)', async () => {
|
||||
primeGitExecWithDefaultBranch()
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
@@ -133,124 +82,6 @@ describe('Gitea client', () => {
|
||||
).resolves.toMatchObject({ number: 42 })
|
||||
})
|
||||
|
||||
it('normalizes Gitea API base URLs', () => {
|
||||
expect(normalizeGiteaApiBaseUrl('https://git.example.com')).toBe(
|
||||
'https://git.example.com/api/v1'
|
||||
)
|
||||
expect(normalizeGiteaApiBaseUrl('https://git.example.com/api/v1/')).toBe(
|
||||
'https://git.example.com/api/v1'
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches a branch pull request and commit status', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const parsed = new URL(url)
|
||||
if (!init) {
|
||||
throw new Error('expected request init')
|
||||
}
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('token gitea-token')
|
||||
if (parsed.pathname.endsWith('/commits/abc123/status')) {
|
||||
return Response.json({ state: 'success' })
|
||||
}
|
||||
return Response.json([giteaPr()])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
getGiteaPullRequestForBranch('/repo', 'refs/heads/feature/gitea')
|
||||
).resolves.toEqual({
|
||||
number: 7,
|
||||
title: 'Add Gitea',
|
||||
state: 'open',
|
||||
url: 'https://git.example.com/team/repo/pulls/7',
|
||||
status: 'success',
|
||||
updatedAt: '2026-05-15T00:00:00Z',
|
||||
mergeable: 'MERGEABLE',
|
||||
headSha: 'abc123'
|
||||
})
|
||||
|
||||
const listUrl = new URL(String(fetchMock.mock.calls[0]?.[0]))
|
||||
expect(listUrl.origin).toBe('https://git.example.com')
|
||||
expect(listUrl.pathname).toBe('/api/v1/repos/team/repo/pulls')
|
||||
expect(listUrl.searchParams.get('state')).toBe('all')
|
||||
expect(listUrl.searchParams.get('sort')).toBe('recentupdate')
|
||||
expect(listUrl.searchParams.get('page')).toBe('1')
|
||||
expect(listUrl.searchParams.get('limit')).toBe('50')
|
||||
})
|
||||
|
||||
it('shares one /pulls scan across concurrent branch lookups (#8807)', async () => {
|
||||
let listCalls = 0
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.pathname.endsWith('/status')) {
|
||||
return Response.json({ state: 'success' })
|
||||
}
|
||||
listCalls++
|
||||
return Response.json([giteaPr(7, 'feature/a'), giteaPr(8, 'feature/b')])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const [a, b, missing] = await Promise.all([
|
||||
getGiteaPullRequestForBranch('/repo', 'feature/a'),
|
||||
getGiteaPullRequestForBranch('/repo', 'feature/b'),
|
||||
getGiteaPullRequestForBranch('/repo', 'feature/none')
|
||||
])
|
||||
|
||||
expect(a?.number).toBe(7)
|
||||
expect(b?.number).toBe(8)
|
||||
expect(missing).toBeNull()
|
||||
expect(listCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('reuses the cached /pulls scan for lookups inside the TTL', async () => {
|
||||
let listCalls = 0
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.pathname.endsWith('/status')) {
|
||||
return Response.json({ state: 'success' })
|
||||
}
|
||||
listCalls++
|
||||
return Response.json([giteaPr()])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await getGiteaPullRequestForBranch('/repo', 'feature/gitea')
|
||||
await getGiteaPullRequestForBranch('/repo', 'feature/gitea')
|
||||
await getGiteaPullRequestForBranch('/repo', 'no-pr-branch')
|
||||
|
||||
expect(listCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('retries a failed /pulls scan after only the short failure cooldown', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let listCalls = 0
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.pathname.endsWith('/status')) {
|
||||
return Response.json({ state: 'success' })
|
||||
}
|
||||
listCalls++
|
||||
return listCalls === 1
|
||||
? Response.json({ message: 'temporary failure' }, { status: 503 })
|
||||
: Response.json([giteaPr()])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(getGiteaPullRequestForBranch('/repo', 'feature/gitea')).resolves.toBeNull()
|
||||
await expect(getGiteaPullRequestForBranch('/repo', 'feature/gitea')).resolves.toBeNull()
|
||||
expect(listCalls).toBe(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_001)
|
||||
await expect(getGiteaPullRequestForBranch('/repo', 'feature/gitea')).resolves.toMatchObject({
|
||||
number: 7
|
||||
})
|
||||
expect(listCalls).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('surfaces a lookup failure via getGiteaPullRequestForBranchOrThrow instead of null (finding 4)', async () => {
|
||||
const fetchMock = vi.fn(async () => Response.json({ message: 'unauthorized' }, { status: 403 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
@@ -279,16 +110,6 @@ describe('Gitea client', () => {
|
||||
expect(requested).toEqual(['/api/v1/repos/team/repo/pulls', '/api/v1/repos/team/repo/pulls/42'])
|
||||
})
|
||||
|
||||
it('resolves null without throwing when the scan returns an empty list', async () => {
|
||||
// Gitea has no head-branch filter, so "branch has no PR" arrives as a 200
|
||||
// with [] from the repo listing — never a 404. The throwing variant must
|
||||
// still report that as a plain "no PR".
|
||||
const fetchMock = vi.fn(async () => Response.json([]))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(getGiteaPullRequestForBranchOrThrow('/repo', 'feature/gitea')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('still throws on a 404 from the /pulls scan so a misconfigured host is not read as "no PR"', async () => {
|
||||
const fetchMock = vi.fn(async () => Response.json({ message: 'not found' }, { status: 404 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
@@ -300,38 +121,6 @@ describe('Gitea client', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('expires successful scans and bounds retained repository listings', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let listCalls = 0
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.pathname.endsWith('/status')) {
|
||||
return Response.json({ state: 'success' })
|
||||
}
|
||||
listCalls++
|
||||
return Response.json([giteaPr()])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await getGiteaPullRequestForBranch('/repo', 'feature/gitea')
|
||||
expect(_getGiteaPullRequestScanCacheSize()).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(30_001)
|
||||
expect(_getGiteaPullRequestScanCacheSize()).toBe(0)
|
||||
await getGiteaPullRequestForBranch('/repo', 'feature/gitea')
|
||||
expect(listCalls).toBe(2)
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 40 }, (_, index) =>
|
||||
scanGiteaPullRequests(`repo-${index}`, async () => [], 50, 5)
|
||||
)
|
||||
)
|
||||
expect(_getGiteaPullRequestScanCacheSize()).toBe(32)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let an in-flight scan re-cache results from before an invalidation', async () => {
|
||||
let releaseFirstScan!: () => void
|
||||
const firstScanGate = new Promise<void>((resolve) => {
|
||||
@@ -366,71 +155,6 @@ describe('Gitea client', () => {
|
||||
expect(listCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('uses an API base URL override for subpath or non-standard deployments', async () => {
|
||||
process.env.ORCA_GITEA_API_BASE_URL = 'https://git.example.com/code'
|
||||
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||
if (String(url).includes('/commits/abc123/status')) {
|
||||
return Response.json({ state: 'pending' })
|
||||
}
|
||||
return Response.json([giteaPr()])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(getGiteaPullRequestForBranch('/repo', 'feature/gitea')).resolves.toMatchObject({
|
||||
number: 7,
|
||||
status: 'pending'
|
||||
})
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toContain(
|
||||
'https://git.example.com/code/api/v1/repos/team/repo/pulls'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to a linked PR number when branch lookup misses', async () => {
|
||||
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||
const requestUrl = String(url)
|
||||
if (requestUrl.includes('/commits/abc123/status')) {
|
||||
return Response.json({ state: 'success' })
|
||||
}
|
||||
if (requestUrl.endsWith('/pulls/42')) {
|
||||
return Response.json(giteaPr(42, 'renamed-local-branch'))
|
||||
}
|
||||
return Response.json([])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(getGiteaPullRequestForBranch('/repo', 'local-name', 42)).resolves.toMatchObject({
|
||||
number: 42,
|
||||
status: 'success'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports configured token auth without a global API base URL', async () => {
|
||||
await expect(getGiteaAuthStatus()).resolves.toEqual({
|
||||
configured: true,
|
||||
authenticated: true,
|
||||
account: null,
|
||||
baseUrl: null,
|
||||
tokenConfigured: true
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies token auth when a global API base URL is configured', async () => {
|
||||
process.env.ORCA_GITEA_API_BASE_URL = 'https://git.example.com'
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
|
||||
Response.json({ login: 'gitea-user' })
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(getGiteaAuthStatus()).resolves.toEqual({
|
||||
configured: true,
|
||||
authenticated: true,
|
||||
account: 'gitea-user',
|
||||
baseUrl: 'https://git.example.com/api/v1',
|
||||
tokenConfigured: true
|
||||
})
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://git.example.com/api/v1/user')
|
||||
})
|
||||
|
||||
it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => {
|
||||
let cancelledBodies = 0
|
||||
const fetchMock = vi.fn(async () =>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { uploadGitHubImage } from './image-attachment-upload'
|
||||
|
||||
const { gh, acquire, release } = vi.hoisted(() => ({
|
||||
gh: vi.fn(),
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn()
|
||||
}))
|
||||
vi.mock('./gh-utils', () => ({
|
||||
ghExecFileAsync: gh,
|
||||
acquire,
|
||||
release,
|
||||
extractExecError: (error: unknown) => ({ stdout: '', stderr: String(error) })
|
||||
}))
|
||||
const repository = {
|
||||
ownerRepo: { owner: 'octo', repo: 'images', host: 'github.com' },
|
||||
ghOptions: { host: 'github.com' }
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
describe('GitHub image attachment upload', () => {
|
||||
it('posts unchanged binary bytes with their exact content length in the resolved gh context', async () => {
|
||||
gh.mockResolvedValueOnce({ stdout: '{"id":123}' }).mockResolvedValueOnce({
|
||||
stdout: '{"url":"https://github.com/user-attachments/assets/a"}'
|
||||
})
|
||||
await expect(
|
||||
uploadGitHubImage({
|
||||
fileName: 'screen.png',
|
||||
contentType: 'image/png',
|
||||
bytes: Buffer.from([0, 128, 255]),
|
||||
resolve: async () => repository
|
||||
})
|
||||
).resolves.toEqual({ url: 'https://github.com/user-attachments/assets/a' })
|
||||
expect(gh.mock.calls[0][0]).toEqual(['api', '--cache', '60s', 'repos/octo/images'])
|
||||
expect(gh.mock.calls[1][0]).toEqual([
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
'https://uploads.github.com/user-attachments/assets?name=screen.png&content_type=image%2Fpng&repository_id=123',
|
||||
'-H',
|
||||
'Content-Type: application/octet-stream',
|
||||
'-H',
|
||||
'Content-Length: 3',
|
||||
'--input',
|
||||
'-'
|
||||
])
|
||||
expect(gh.mock.calls[1][1]).toMatchObject({
|
||||
host: 'github.com',
|
||||
idempotent: false,
|
||||
stdin: Buffer.from([0, 128, 255])
|
||||
})
|
||||
})
|
||||
it('reports a rejected upload without retrying the POST and releases admission', async () => {
|
||||
gh.mockResolvedValueOnce({ stdout: '{"id":123}' }).mockRejectedValueOnce(new Error('HTTP 403'))
|
||||
await expect(
|
||||
uploadGitHubImage({
|
||||
fileName: 'screen.png',
|
||||
contentType: 'image/png',
|
||||
bytes: Buffer.from([1]),
|
||||
resolve: async () => repository
|
||||
})
|
||||
).rejects.toThrow('selected account')
|
||||
expect(gh.mock.calls.map((call) => call[0][0])).toEqual(['api', 'api'])
|
||||
expect(release).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,281 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GlUtils from './gl-utils'
|
||||
|
||||
const {
|
||||
glabExecFileAsyncMock,
|
||||
getGlabKnownHostsMock,
|
||||
getProjectRefMock,
|
||||
acquireMock,
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
glabExecFileAsyncMock: vi.fn(),
|
||||
getGlabKnownHostsMock: vi.fn(),
|
||||
getProjectRefMock: vi.fn(),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./gl-utils', async () => {
|
||||
const actual = await vi.importActual<typeof GlUtils>('./gl-utils')
|
||||
return {
|
||||
...actual,
|
||||
glabExecFileAsync: glabExecFileAsyncMock,
|
||||
getGlabKnownHosts: getGlabKnownHostsMock,
|
||||
getProjectRef: getProjectRefMock,
|
||||
acquire: acquireMock,
|
||||
release: releaseMock
|
||||
}
|
||||
})
|
||||
|
||||
import { getAuthenticatedViewer, getWorkItemByProjectRef, listTodos } from './client'
|
||||
|
||||
describe('gitlab client — viewer & paste-URL lookup', () => {
|
||||
beforeEach(() => {
|
||||
glabExecFileAsyncMock.mockReset()
|
||||
getGlabKnownHostsMock.mockReset()
|
||||
getProjectRefMock.mockReset()
|
||||
acquireMock.mockReset()
|
||||
releaseMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
|
||||
getProjectRefMock.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
describe('getAuthenticatedViewer', () => {
|
||||
it('returns username + email when glab api user succeeds', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ username: 'alice', email: 'alice@example.com' })
|
||||
})
|
||||
await expect(getAuthenticatedViewer()).resolves.toEqual({
|
||||
username: 'alice',
|
||||
email: 'alice@example.com'
|
||||
})
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['api', 'user'])
|
||||
expect(acquireMock).toHaveBeenCalledOnce()
|
||||
expect(releaseMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coerces a missing email to null', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ username: 'alice', email: null })
|
||||
})
|
||||
await expect(getAuthenticatedViewer()).resolves.toEqual({
|
||||
username: 'alice',
|
||||
email: null
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when glab fails', async () => {
|
||||
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not authenticated'))
|
||||
await expect(getAuthenticatedViewer()).resolves.toBeNull()
|
||||
expect(releaseMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns null when username is empty', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ username: ' ', email: null })
|
||||
})
|
||||
await expect(getAuthenticatedViewer()).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkItemByProjectRef', () => {
|
||||
it('fetches an MR and maps to GitLabWorkItem', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
id: 100,
|
||||
iid: 5,
|
||||
title: 't',
|
||||
state: 'opened',
|
||||
web_url: 'https://gitlab.com/g/p/-/merge_requests/5',
|
||||
source_branch: 'feat',
|
||||
target_branch: 'main'
|
||||
})
|
||||
})
|
||||
const item = await getWorkItemByProjectRef(
|
||||
'/repo',
|
||||
{ host: 'gitlab.com', path: 'g/p' },
|
||||
5,
|
||||
'mr'
|
||||
)
|
||||
expect(item).toMatchObject({ type: 'mr', number: 5, branchName: 'feat' })
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['api', '--hostname', 'gitlab.com', 'projects/g%2Fp/merge_requests/5'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches an issue and maps to GitLabWorkItem', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
id: 200,
|
||||
iid: 9,
|
||||
title: 'bug',
|
||||
state: 'opened',
|
||||
web_url: 'https://gitlab.com/g/p/-/issues/9'
|
||||
})
|
||||
})
|
||||
const item = await getWorkItemByProjectRef(
|
||||
'/repo',
|
||||
{ host: 'gitlab.com', path: 'g/p' },
|
||||
9,
|
||||
'issue'
|
||||
)
|
||||
expect(item).toMatchObject({ type: 'issue', number: 9 })
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['api', '--hostname', 'gitlab.com', 'projects/g%2Fp/issues/9'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it('passes self-hosted hostname for local pasted URL lookups', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
id: 201,
|
||||
iid: 9,
|
||||
title: 'bug',
|
||||
state: 'opened',
|
||||
web_url: 'https://gitlab.internal/g/p/-/issues/9'
|
||||
})
|
||||
})
|
||||
|
||||
await getWorkItemByProjectRef('/repo', { host: 'gitlab.internal', path: 'g/p' }, 9, 'issue')
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['api', '--hostname', 'gitlab.internal', 'projects/g%2Fp/issues/9'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it('routes local WSL pasted-project lookup through glab execution options', async () => {
|
||||
const localGitOptions = { wslDistro: 'Ubuntu' }
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
id: 201,
|
||||
iid: 9,
|
||||
title: 'WSL bug',
|
||||
state: 'opened',
|
||||
web_url: 'https://gitlab.com/g/p/-/issues/9'
|
||||
})
|
||||
})
|
||||
|
||||
await getWorkItemByProjectRef(
|
||||
'/repo',
|
||||
{ host: 'gitlab.com', path: 'g/p' },
|
||||
9,
|
||||
'issue',
|
||||
null,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['api', '--hostname', 'gitlab.com', 'projects/g%2Fp/issues/9'],
|
||||
{
|
||||
cwd: '/repo',
|
||||
wslDistro: 'Ubuntu'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when the API errors', async () => {
|
||||
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not found'))
|
||||
const item = await getWorkItemByProjectRef(
|
||||
'/repo',
|
||||
{ host: 'gitlab.com', path: 'g/p' },
|
||||
9,
|
||||
'issue'
|
||||
)
|
||||
expect(item).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listTodos', () => {
|
||||
it('maps glab todos response to GitLabTodo shape', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
action_name: 'assigned',
|
||||
target_type: 'MergeRequest',
|
||||
target: {
|
||||
iid: 42,
|
||||
title: 'Add feature',
|
||||
web_url: 'https://gitlab.com/g/p/-/merge_requests/42'
|
||||
},
|
||||
target_url: 'https://gitlab.com/g/p/-/merge_requests/42',
|
||||
author: { username: 'alice', avatar_url: 'https://example.com/a.png' },
|
||||
project: { path_with_namespace: 'g/p' },
|
||||
updated_at: '2026-05-08T10:00:00Z',
|
||||
state: 'pending'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
await expect(listTodos('/repo')).resolves.toEqual([
|
||||
{
|
||||
id: 1,
|
||||
actionName: 'assigned',
|
||||
targetType: 'MergeRequest',
|
||||
targetIid: 42,
|
||||
targetTitle: 'Add feature',
|
||||
targetUrl: 'https://gitlab.com/g/p/-/merge_requests/42',
|
||||
projectPath: 'g/p',
|
||||
authorUsername: 'alice',
|
||||
authorAvatarUrl: 'https://example.com/a.png',
|
||||
updatedAt: '2026-05-08T10:00:00Z',
|
||||
state: 'pending'
|
||||
}
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['api', 'todos?state=pending&per_page=50'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it('routes local WSL todos through project resolution and glab execution options', async () => {
|
||||
const localGitOptions = { wslDistro: 'Ubuntu' }
|
||||
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await expect(listTodos('/repo', null, localGitOptions)).resolves.toEqual([])
|
||||
|
||||
expect(getProjectRefMock).toHaveBeenCalledWith('/repo', ['gitlab.com'], null, localGitOptions)
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['api', 'todos?state=pending&per_page=50'],
|
||||
{ cwd: '/repo', wslDistro: 'Ubuntu' }
|
||||
)
|
||||
})
|
||||
|
||||
it('coerces non-pending state values to pending (defensive)', async () => {
|
||||
// Preserve the pending/done union even if future API output ignores the pending filter.
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{ id: 2, action_name: 'mentioned', target_type: 'Issue', state: 'weird' }
|
||||
])
|
||||
})
|
||||
const result = await listTodos('/repo')
|
||||
expect(result[0].state).toBe('pending')
|
||||
})
|
||||
|
||||
it('falls back to empty list when glab errors', async () => {
|
||||
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('auth failed'))
|
||||
await expect(listTodos('/repo')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('handles missing target / project / author fields gracefully', async () => {
|
||||
// Commit/Note todos can omit target; retain a well-formed display record.
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([{ id: 3, action_name: 'build_failed', target_type: 'Commit' }])
|
||||
})
|
||||
const result = await listTodos('/repo')
|
||||
expect(result[0]).toMatchObject({
|
||||
targetIid: null,
|
||||
targetTitle: '',
|
||||
targetUrl: '',
|
||||
projectPath: '',
|
||||
authorUsername: '',
|
||||
authorAvatarUrl: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -26,13 +26,8 @@ vi.mock('os', async () => {
|
||||
homedir: homedirMock
|
||||
}
|
||||
})
|
||||
|
||||
import { getGrokToolEventMatcherForTests, GrokHookService } from './hook-service'
|
||||
import { GrokHookService } from './hook-service'
|
||||
import { buildWindowsGrokHookScript } from './windows-grok-hook-script'
|
||||
import {
|
||||
POSIX_HOOK_JSON_STDIN_PRELUDE,
|
||||
POSIX_HOOK_JSON_STDIN_READER
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh'
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
@@ -129,21 +124,6 @@ describe('GrokHookService', () => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('guards Windows GROK_HOME substring checks when empty (#9358)', () => {
|
||||
const script = buildWindowsGrokHookScript()
|
||||
expect(script).toContain('set "ORCA_GROK_HOME="')
|
||||
expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready')
|
||||
expect(script).toContain('%GROK_HOME:~4096,1%')
|
||||
expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME:"=%"')
|
||||
expect(script).toContain('%ORCA_GROK_HOME:~4096,1%')
|
||||
expect(script).toContain(':orca_grok_home_ready')
|
||||
expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready')
|
||||
expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"')
|
||||
expect(script).toContain('if not "%GROK_HOME:~4096,1%"=="" goto :orca_grok_home_ready')
|
||||
// Parenthesized blocks still expand percent variables before their guard executes.
|
||||
expect(script).not.toMatch(/if defined GROK_HOME \(/)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'generated grok-hook.cmd exits 0 when GROK_HOME is unset (#9358)',
|
||||
async () => {
|
||||
@@ -229,126 +209,7 @@ describe('GrokHookService', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('installs a dedicated global Grok hook config and managed script', () => {
|
||||
const status = new GrokHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.configPath).toBe(join(homeDir, '.grok', 'hooks', 'orca-status.json'))
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(homeDir, '.grok', 'hooks', 'orca-status.json'), 'utf8')
|
||||
) as {
|
||||
hooks: Record<string, { matcher?: string; hooks: { command: string }[] }[]>
|
||||
}
|
||||
expect(Object.keys(config.hooks).sort()).toEqual(
|
||||
[
|
||||
'Notification',
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'SessionEnd',
|
||||
'SessionStart',
|
||||
'Stop',
|
||||
'StopCancelled',
|
||||
'StopFailure',
|
||||
'UserPromptSubmit'
|
||||
].sort()
|
||||
)
|
||||
// PreToolUse is needed for in-flight tools and question waits.
|
||||
expect(config.hooks.PreToolUse[0].matcher).toBe('.*')
|
||||
expect(registersManagedGrokScript(config.hooks.PreToolUse[0].hooks[0].command)).toBe(true)
|
||||
// Grok uses real regex matchers; bare * is invalid.
|
||||
expect(config.hooks.PostToolUseFailure[0].matcher).toBe('.*')
|
||||
expect(config.hooks.PostToolUse[0].matcher).toBe('.*')
|
||||
expect(config.hooks.StopCancelled[0].matcher).toBeUndefined()
|
||||
expect(config.hooks.StopFailure[0].matcher).toBeUndefined()
|
||||
expect(config.hooks.Notification[0].matcher).toBeUndefined()
|
||||
expect(getGrokToolEventMatcherForTests()).toBe('.*')
|
||||
expect(getGrokToolEventMatcherForTests()).not.toBe('*')
|
||||
expect(new RegExp(getGrokToolEventMatcherForTests()).test('run_terminal_command')).toBe(true)
|
||||
// Construct invalid regex dynamically to avoid static lint rejection.
|
||||
const bareStar = ['*', ''].join('')
|
||||
expect(() => new RegExp(bareStar)).toThrow()
|
||||
expect(registersManagedGrokScript(config.hooks.PostToolUse[0].hooks[0].command)).toBe(true)
|
||||
if (process.platform !== 'win32') {
|
||||
const command = config.hooks.PostToolUse[0].hooks[0].command
|
||||
expect(command).toContain(join(homeDir, '.orca'))
|
||||
expect(command).toMatch(/^if \[ -n "\$\{ORCA_PANE_KEY-\}" \] && /)
|
||||
}
|
||||
|
||||
const script = readFileSync(
|
||||
join(homeDir, '.orca', 'agent-hooks', GROK_SCRIPT_FILE_NAME),
|
||||
'utf8'
|
||||
)
|
||||
expect(script).toContain('/hook/grok')
|
||||
if (process.platform === 'win32') {
|
||||
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
|
||||
expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME:"=%"')
|
||||
expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"')
|
||||
} else {
|
||||
// Stream payload to curl stdin; reader variables must be initialized before capture.
|
||||
const prelude = POSIX_HOOK_JSON_STDIN_PRELUDE.join('\n')
|
||||
expect(script).toContain(prelude)
|
||||
expect(script.indexOf(prelude)).toBeLessThan(
|
||||
script.indexOf(`payload=$(${POSIX_HOOK_JSON_STDIN_READER})`)
|
||||
)
|
||||
expect(script).toContain(`payload=$(${POSIX_HOOK_JSON_STDIN_READER})`)
|
||||
expect(script).toContain('printf \'%s\' "$payload" | LC_ALL= LC_NUMERIC=C curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).toContain('${#GROK_HOME}" -le 4096')
|
||||
expect(script).toContain('--data-urlencode "grokHome=${grok_home}"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
}
|
||||
})
|
||||
|
||||
// Profile-path spaces need the encoded fallback so Grok cannot split the managed path.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command to survive spaces in the profile path (#6078)',
|
||||
() => {
|
||||
const spaceHome = join(tmpdir(), 'orca grok home with spaces')
|
||||
mkdirSync(spaceHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(spaceHome)
|
||||
try {
|
||||
expect(new GrokHookService().install().state).toBe('installed')
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(spaceHome, '.grok', 'hooks', 'orca-status.json'), 'utf8')
|
||||
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
|
||||
|
||||
for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) {
|
||||
const command = config.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
|
||||
}
|
||||
} finally {
|
||||
rmSync(spaceHome, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('installs hooks under GROK_HOME when set', () => {
|
||||
const grokHome = mkdtempSync(join(tmpdir(), 'orca-grok-home-env-'))
|
||||
const previous = process.env.GROK_HOME
|
||||
process.env.GROK_HOME = grokHome
|
||||
try {
|
||||
const status = new GrokHookService().install()
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.configPath).toBe(join(grokHome, 'hooks', 'orca-status.json'))
|
||||
expect(readFileSync(join(grokHome, 'hooks', 'orca-status.json'), 'utf8')).toContain(
|
||||
'SessionStart'
|
||||
)
|
||||
expect(() =>
|
||||
readFileSync(join(homeDir, '.grok', 'hooks', 'orca-status.json'), 'utf8')
|
||||
).toThrow()
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.GROK_HOME
|
||||
} else {
|
||||
process.env.GROK_HOME = previous
|
||||
}
|
||||
rmSync(grokHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not restore a user-cleared global hook config on the next launch', () => {
|
||||
const service = new GrokHookService()
|
||||
@@ -375,15 +236,6 @@ describe('GrokHookService', () => {
|
||||
expect(readFileSync(configPath, 'utf8')).toBe(userClearedConfig)
|
||||
})
|
||||
|
||||
it('removes the empty managed config when hooks are disabled', () => {
|
||||
const service = new GrokHookService()
|
||||
const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json')
|
||||
|
||||
service.install()
|
||||
expect(service.remove().state).toBe('not_installed')
|
||||
expect(() => readFileSync(configPath, 'utf8')).toThrow()
|
||||
})
|
||||
|
||||
it('removes the managed config even when a non-hook key remains', () => {
|
||||
const service = new GrokHookService()
|
||||
const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json')
|
||||
@@ -400,18 +252,6 @@ describe('GrokHookService', () => {
|
||||
})
|
||||
|
||||
// Respect startup opt-out while allowing explicit re-enabling to override it.
|
||||
it('leaves a user-cleared config alone on a startup install', () => {
|
||||
const service = new GrokHookService()
|
||||
const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json')
|
||||
const userCleared = '{ "hooks" : {} }\n'
|
||||
|
||||
expect(service.install().state).toBe('installed')
|
||||
writeFileSync(configPath, userCleared, 'utf8')
|
||||
|
||||
service.install()
|
||||
|
||||
expect(readFileSync(configPath, 'utf8')).toBe(userCleared)
|
||||
})
|
||||
|
||||
it('restores a user-cleared config when the user turns hooks back on', () => {
|
||||
const service = new GrokHookService()
|
||||
|
||||
@@ -31,24 +31,6 @@ describe('HermesHookService', () => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('installs the managed Hermes plugin and enables it in config.yaml', () => {
|
||||
const status = new HermesHookService().install()
|
||||
|
||||
expect(status).toMatchObject({
|
||||
agent: 'hermes',
|
||||
state: 'installed',
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
})
|
||||
const pluginDir = join(homeDir, 'plugins', _internals.HERMES_PLUGIN_NAME)
|
||||
expect(readFileSync(join(pluginDir, 'plugin.yaml'), 'utf-8')).toContain('provides_hooks:')
|
||||
expect(readFileSync(join(pluginDir, '__init__.py'), 'utf-8')).toContain('/hook/hermes')
|
||||
const config = parse(readFileSync(join(homeDir, 'config.yaml'), 'utf-8')) as {
|
||||
plugins: { enabled: string[] }
|
||||
}
|
||||
expect(config.plugins.enabled).toContain(_internals.HERMES_PLUGIN_NAME)
|
||||
})
|
||||
|
||||
it('preserves other enabled plugins and removes Orca from disabled list', () => {
|
||||
writeFileSync(
|
||||
join(homeDir, 'config.yaml'),
|
||||
@@ -217,38 +199,6 @@ describe('HermesHookService', () => {
|
||||
expect(config.plugins.disabled).toBe('not-a-list')
|
||||
})
|
||||
|
||||
it('reports partial when the plugin exists but is not enabled', () => {
|
||||
new HermesHookService().install()
|
||||
const update = _internals.updateConfigContent(
|
||||
readFileSync(join(homeDir, 'config.yaml'), 'utf-8'),
|
||||
_internals.disablePlugin
|
||||
)
|
||||
expect(update.content).toBeTruthy()
|
||||
writeFileSync(join(homeDir, 'config.yaml'), update.content!, 'utf-8')
|
||||
|
||||
const status = new HermesHookService().getStatus()
|
||||
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.detail).toContain('not enabled')
|
||||
})
|
||||
|
||||
it('is visible to the real hermes CLI when hermes is installed', () => {
|
||||
const hermesAvailable = spawnSync('hermes', ['--version'], { encoding: 'utf-8' }).status === 0
|
||||
if (!hermesAvailable) {
|
||||
return
|
||||
}
|
||||
new HermesHookService().install()
|
||||
|
||||
const output = execFileSync('hermes', ['plugins', 'list'], {
|
||||
env: { ...process.env, HERMES_HOME: homeDir },
|
||||
encoding: 'utf-8',
|
||||
timeout: 15_000
|
||||
})
|
||||
|
||||
expect(output).toContain(_internals.HERMES_PLUGIN_NAME)
|
||||
expect(output.toLowerCase()).toContain('enabled')
|
||||
}, 20_000)
|
||||
|
||||
it('registered plugin hooks post normalized JSON to Orca', async () => {
|
||||
const pythonAvailable = spawnSync('python3', ['--version'], { encoding: 'utf-8' }).status === 0
|
||||
if (!pythonAvailable) {
|
||||
|
||||
@@ -5,8 +5,6 @@ vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), removeHandler: vi.fn()
|
||||
import type { SshTarget } from '../../../shared/ssh-types'
|
||||
import {
|
||||
disallowDesktopBrowserBridgeHost,
|
||||
noticeDesktopBrowserBridgeHostControl,
|
||||
stopDesktopBrowserBridgeHost,
|
||||
type DesktopBrowserBridgeHostControlDeps
|
||||
} from './desktop-browser-bridge-host-control'
|
||||
import {
|
||||
@@ -15,8 +13,6 @@ import {
|
||||
setDesktopBrowserBridgeHostStopped
|
||||
} from './desktop-browser-bridge-reconcile'
|
||||
|
||||
const NOTICE_CHANNEL = 'browser:remoteHostControlNotice'
|
||||
|
||||
function setup(targets: SshTarget[] = []) {
|
||||
let noticed: string[] | undefined
|
||||
const byId = new Map(targets.map((target) => [target.id, target]))
|
||||
@@ -74,52 +70,6 @@ describe('desktop browser bridge host control', () => {
|
||||
setDesktopBrowserBridgeControl(null)
|
||||
})
|
||||
|
||||
it('tells the person once per host, and waits for a window before counting it shown', () => {
|
||||
const { deps, sent, mainWindow } = setup([userHost, orcaOwnedHost])
|
||||
const host = { targetId: userHost.id, hostLabel: 'build-box' }
|
||||
|
||||
noticeDesktopBrowserBridgeHostControl(host, () => null, deps)
|
||||
expect(sent).toEqual([])
|
||||
|
||||
noticeDesktopBrowserBridgeHostControl(host, mainWindow, deps)
|
||||
noticeDesktopBrowserBridgeHostControl(host, mainWindow, deps)
|
||||
noticeDesktopBrowserBridgeHostControl(
|
||||
{ targetId: orcaOwnedHost.id, hostLabel: 'sandbox' },
|
||||
mainWindow,
|
||||
deps
|
||||
)
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
channel: NOTICE_CHANNEL,
|
||||
payload: { targetId: 'ssh-1', hostLabel: 'build-box', canDisallow: true }
|
||||
},
|
||||
{
|
||||
channel: NOTICE_CHANNEL,
|
||||
payload: { targetId: 'runtime-ssh-1', hostLabel: 'sandbox', canDisallow: false }
|
||||
}
|
||||
])
|
||||
expect(deps.settings?.getSettings().desktopBrowserBridgeNoticedTargetIds).toEqual([
|
||||
'ssh-1',
|
||||
'runtime-ssh-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('Stop detaches the host and clears its tabs without touching its setting', () => {
|
||||
const { deps, reconcile, byId } = setup([userHost])
|
||||
|
||||
stopDesktopBrowserBridgeHost(userHost.id, deps)
|
||||
|
||||
expect(isDesktopBrowserBridgeHostStopped(userHost.id)).toBe(true)
|
||||
expect(reconcile).toHaveBeenCalledTimes(1)
|
||||
expect(deps.endHostActivity).toHaveBeenCalledWith('ssh-1')
|
||||
expect(byId.get(userHost.id)?.allowRemoteCliControl).toBeUndefined()
|
||||
|
||||
setDesktopBrowserBridgeHostStopped(userHost.id, false)
|
||||
expect(isDesktopBrowserBridgeHostStopped(userHost.id)).toBe(false)
|
||||
expect(reconcile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("Don't allow turns the host's setting off, and only stops hosts Orca created", () => {
|
||||
const { deps, byId } = setup([userHost, orcaOwnedHost])
|
||||
|
||||
|
||||
@@ -106,14 +106,4 @@ describe('classifyLinkedFile', () => {
|
||||
}
|
||||
expect(classifyLinkedFile(facts, platform, PATHEXT)).toBe(expected)
|
||||
})
|
||||
|
||||
it('reveals sockets, devices and other non-regular files', () => {
|
||||
const facts = {
|
||||
names: ['/x/sock'],
|
||||
isFile: false,
|
||||
isDirectory: false,
|
||||
mode: 0o755
|
||||
}
|
||||
expect(classifyLinkedFile({ ...facts, head: new Uint8Array() }, 'linux')).toBe('reveal')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,11 +24,6 @@ describe.skipIf(process.platform === 'win32')('inspectLinkedFile', () => {
|
||||
return path
|
||||
}
|
||||
|
||||
it('opens documents and shows scripts as text', async () => {
|
||||
expect(await inspectLinkedFile(await file('notes.md', '# Notes\n'))).toBe('open')
|
||||
expect(await inspectLinkedFile(await file('run.sh', 'echo hi\n'))).toBe('view-as-text')
|
||||
})
|
||||
|
||||
it('reads the content of +x files whatever their name', async () => {
|
||||
expect(await inspectLinkedFile(await file('notes.md', '#!/bin/sh\n', 0o755))).toBe(
|
||||
'view-as-text'
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import '../runtime/rpc/unused-default-rpc-methods.test-fixture'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeOs from 'node:os'
|
||||
|
||||
// Pins the desktop's Settings pairing handlers: every branch's reply and the runtime calls it makes.
|
||||
const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
networkInterfacesMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { isPackaged: false },
|
||||
ipcMain: { handle: handleMock },
|
||||
shell: { openExternal: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('os', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeOs>()),
|
||||
networkInterfaces: networkInterfacesMock
|
||||
}))
|
||||
|
||||
import type { MobilePairingQrResult } from '../runtime/rpc/server/pairing/mobile-pairing-qr'
|
||||
import {
|
||||
MOBILE_PAIRING_OFFER_TTL_MS,
|
||||
RUNTIME_PAIRING_OFFER_TTL_MS
|
||||
} from '../runtime/rpc/server/pairing/pairing-offer-expiry'
|
||||
import { registerMobileHandlers } from './mobile'
|
||||
|
||||
const LAN = {
|
||||
en0: [{ address: '192.168.1.20', family: 'IPv4', internal: false, netmask: '255.255.255.0' }]
|
||||
}
|
||||
|
||||
type Call = { method: string; args?: unknown }
|
||||
|
||||
function fakeServer(options: {
|
||||
mobileOffer?: Record<string, unknown>
|
||||
runtimeOffer?: Record<string, unknown>
|
||||
exposureFails?: boolean
|
||||
}) {
|
||||
const calls: Call[] = []
|
||||
const server = {
|
||||
getNetworkExposureBindHost: () => '0.0.0.0',
|
||||
ensureNetworkExposure: async () => {
|
||||
calls.push({ method: 'ensureNetworkExposure' })
|
||||
if (options.exposureFails) {
|
||||
throw new Error('bind refused')
|
||||
}
|
||||
},
|
||||
createMobilePairingOffer: async (args: Record<string, unknown>) => {
|
||||
calls.push({ method: 'createMobilePairingOffer', args: { ...args, name: typeof args.name } })
|
||||
return options.mobileOffer
|
||||
},
|
||||
createPairingOffer: (args: Record<string, unknown>) => {
|
||||
calls.push({ method: 'createPairingOffer', args: { ...args, name: typeof args.name } })
|
||||
return options.runtimeOffer
|
||||
}
|
||||
}
|
||||
return { server, calls }
|
||||
}
|
||||
|
||||
const MOBILE_OFFER = {
|
||||
available: true,
|
||||
pairingUrl: 'orca://pair#x',
|
||||
endpoint: 'ws://192.168.1.20:6768',
|
||||
deviceId: 'dev-1',
|
||||
connectionMode: 'local-only',
|
||||
webClientUrl: null,
|
||||
offerExpiresAt: 1_000
|
||||
}
|
||||
const RUNTIME_OFFER = {
|
||||
available: true,
|
||||
pairingUrl: 'orca://pair#r',
|
||||
webClientUrl: 'http://192.168.1.20:6768/#r',
|
||||
endpoint: 'ws://192.168.1.20:6768',
|
||||
deviceId: 'dev-2',
|
||||
offerExpiresAt: 2_000
|
||||
}
|
||||
|
||||
describe('desktop pairing IPC handlers', () => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
||||
const encodePairingQr = async (): Promise<MobilePairingQrResult> => ({
|
||||
ok: true,
|
||||
qrDataUrl: 'data:qr',
|
||||
qrSize: 21
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
networkInterfacesMock.mockReset().mockReturnValue(LAN)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
async function call(
|
||||
channel: string,
|
||||
server: ReturnType<typeof fakeServer>['server'],
|
||||
args?: unknown
|
||||
) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test call only the four methods the fake implements.
|
||||
registerMobileHandlers(server as never, {
|
||||
encodePairingQr,
|
||||
getDefaultRouteInterfaceNames: async () => new Set(['en0'])
|
||||
})
|
||||
return handlers.get(channel)?.({}, args)
|
||||
}
|
||||
|
||||
describe('mobile:getPairingQR', () => {
|
||||
it('mints at an explicit address', async () => {
|
||||
const { server, calls } = fakeServer({ mobileOffer: MOBILE_OFFER })
|
||||
expect(
|
||||
await call('mobile:getPairingQR', server, {
|
||||
address: '100.64.1.20',
|
||||
connectionMode: 'local-only',
|
||||
rotate: true
|
||||
})
|
||||
).toEqual({
|
||||
available: true,
|
||||
qrDataUrl: 'data:qr',
|
||||
qrSize: 21,
|
||||
pairingUrl: 'orca://pair#x',
|
||||
endpoint: 'ws://192.168.1.20:6768',
|
||||
deviceId: 'dev-1',
|
||||
connectionMode: 'local-only',
|
||||
expiresAt: 1_000
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
method: 'createMobilePairingOffer',
|
||||
args: {
|
||||
address: '100.64.1.20',
|
||||
connectionMode: 'local-only',
|
||||
rotate: true,
|
||||
name: 'string',
|
||||
offerTtlMs: MOBILE_PAIRING_OFFER_TTL_MS
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the default LAN address', async () => {
|
||||
const { server, calls } = fakeServer({ mobileOffer: MOBILE_OFFER })
|
||||
await call('mobile:getPairingQR', server, { connectionMode: 'automatic' })
|
||||
expect(calls[0]?.args).toMatchObject({ address: '192.168.1.20' })
|
||||
})
|
||||
|
||||
it('refuses LAN-only pairing with no usable address and mints nothing', async () => {
|
||||
networkInterfacesMock.mockReturnValue({})
|
||||
const { server, calls } = fakeServer({ mobileOffer: MOBILE_OFFER })
|
||||
expect(await call('mobile:getPairingQR', server, { connectionMode: 'local-only' })).toEqual({
|
||||
available: false,
|
||||
reason: 'invalid_advertised_endpoint',
|
||||
guidance:
|
||||
'No reachable network address is available for pairing. Connect to Wi‑Fi or Tailscale, or pick an address manually.'
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('still asks for a Relay offer with no address, and hides the unadvertised endpoint', async () => {
|
||||
networkInterfacesMock.mockReturnValue({})
|
||||
const { server, calls } = fakeServer({ mobileOffer: MOBILE_OFFER })
|
||||
expect(await call('mobile:getPairingQR', server, { connectionMode: 'automatic' })).toEqual(
|
||||
expect.objectContaining({ available: true, endpoint: null })
|
||||
)
|
||||
expect(calls[0]?.args).toMatchObject({ address: null, connectionMode: 'automatic' })
|
||||
})
|
||||
|
||||
it('passes through an unavailable offer with its Relay failure', async () => {
|
||||
const relayFailure = { kind: 'install_missing' }
|
||||
const { server } = fakeServer({
|
||||
mobileOffer: { available: false, reason: 'relay_unavailable', guidance: 'g', relayFailure }
|
||||
})
|
||||
expect(await call('mobile:getPairingQR', server, { connectionMode: 'automatic' })).toEqual({
|
||||
available: false,
|
||||
reason: 'relay_unavailable',
|
||||
guidance: 'g',
|
||||
relayFailure
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobile:getRuntimePairingUrl', () => {
|
||||
it('widens, then mints a network-reach runtime link', async () => {
|
||||
const { server, calls } = fakeServer({ runtimeOffer: RUNTIME_OFFER })
|
||||
expect(await call('mobile:getRuntimePairingUrl', server, { rotate: true })).toEqual({
|
||||
available: true,
|
||||
pairingUrl: 'orca://pair#r',
|
||||
webClientUrl: 'http://192.168.1.20:6768/#r',
|
||||
endpoint: 'ws://192.168.1.20:6768',
|
||||
deviceId: 'dev-2',
|
||||
expiresAt: 2_000
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
{ method: 'ensureNetworkExposure' },
|
||||
{
|
||||
method: 'createPairingOffer',
|
||||
args: {
|
||||
address: '192.168.1.20',
|
||||
rotate: true,
|
||||
name: 'string',
|
||||
scope: 'runtime',
|
||||
reach: 'network',
|
||||
offerTtlMs: RUNTIME_PAIRING_OFFER_TTL_MS
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps this-computer reach on loopback without widening', async () => {
|
||||
const { server, calls } = fakeServer({ runtimeOffer: RUNTIME_OFFER })
|
||||
await call('mobile:getRuntimePairingUrl', server, {
|
||||
address: '127.0.0.1',
|
||||
reach: 'this-computer'
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
method: 'createPairingOffer',
|
||||
args: expect.objectContaining({ address: '127.0.0.1', reach: 'this-computer' })
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('widens for this-computer intent at a non-loopback address', async () => {
|
||||
const { server, calls } = fakeServer({ runtimeOffer: RUNTIME_OFFER })
|
||||
await call('mobile:getRuntimePairingUrl', server, {
|
||||
address: '192.168.1.20',
|
||||
reach: 'this-computer'
|
||||
})
|
||||
expect(calls.map((entry) => entry.method)).toEqual([
|
||||
'ensureNetworkExposure',
|
||||
'createPairingOffer'
|
||||
])
|
||||
expect(calls[1]?.args).toMatchObject({ reach: 'network' })
|
||||
})
|
||||
|
||||
it('reports a failed widening with guidance and mints nothing', async () => {
|
||||
const { server, calls } = fakeServer({ runtimeOffer: RUNTIME_OFFER, exposureFails: true })
|
||||
expect(await call('mobile:getRuntimePairingUrl', server)).toEqual({
|
||||
available: false,
|
||||
reason: 'network_exposure_failed',
|
||||
guidance: expect.any(String)
|
||||
})
|
||||
expect(calls).toEqual([{ method: 'ensureNetworkExposure' }])
|
||||
})
|
||||
|
||||
it('answers a bare unavailable with no address or no offer', async () => {
|
||||
networkInterfacesMock.mockReturnValue({})
|
||||
const none = fakeServer({ runtimeOffer: RUNTIME_OFFER })
|
||||
expect(await call('mobile:getRuntimePairingUrl', none.server)).toEqual({ available: false })
|
||||
expect(none.calls).toEqual([])
|
||||
|
||||
networkInterfacesMock.mockReturnValue(LAN)
|
||||
const refused = fakeServer({
|
||||
runtimeOffer: { available: false, reason: 'device_registry_unavailable', guidance: 'g' }
|
||||
})
|
||||
expect(await call('mobile:getRuntimePairingUrl', refused.server)).toEqual({
|
||||
available: false
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -109,14 +109,6 @@ describe('a request to a recipe VM environment', () => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('goes through to a running VM, holding a workspace open with its owner', async () => {
|
||||
recordVm('running')
|
||||
await expect(codeOf('none')).resolves.toBeNull()
|
||||
expect(holdOpen).not.toHaveBeenCalled()
|
||||
await expect(codeOf('wake')).resolves.toBeNull()
|
||||
expect(holdOpen).toHaveBeenCalledWith('workspace-of-vm-1', false, 180_000, undefined)
|
||||
})
|
||||
|
||||
it.each(['suspended', 'resume_failed'] as const)(
|
||||
'leaves a %s VM asleep for a request that does not open its workspace',
|
||||
async (status) => {
|
||||
@@ -127,18 +119,6 @@ describe('a request to a recipe VM environment', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['up', null],
|
||||
['waking', 'runtime_environment_waking'],
|
||||
['wake-failed', 'runtime_environment_wake_failed'],
|
||||
['slept', 'runtime_environment_asleep']
|
||||
] as const)('answers a held open that ended %s', async (opening, code) => {
|
||||
recordVm('suspended')
|
||||
ownerOpening = opening
|
||||
await expect(codeOf('wake')).resolves.toBe(code)
|
||||
expect(holdOpen).toHaveBeenCalledWith('workspace-of-vm-1', true, 180_000, undefined)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['waking', 'runtime_environment_waking'],
|
||||
['asleep', 'runtime_environment_asleep']
|
||||
@@ -152,11 +132,6 @@ describe('a request to a recipe VM environment', () => {
|
||||
)
|
||||
|
||||
// A phone gives up after 30 seconds, so its open is held for less.
|
||||
it("holds an open only as long as its caller's preparation allows", async () => {
|
||||
recordVm('suspended')
|
||||
await prepareRecipeVm(userDataPath, VM_ENVIRONMENT, 'wake', 25_000)
|
||||
expect(holdOpen).toHaveBeenCalledWith('workspace-of-vm-1', true, 25_000, undefined)
|
||||
})
|
||||
|
||||
// A Disconnect made before the open holds: no client's open wakes the VM behind that server.
|
||||
it('refuses an open of a server the user disconnected, before waking anything', async () => {
|
||||
@@ -211,10 +186,4 @@ describe('a request to a recipe VM environment', () => {
|
||||
await expect(codeOf('none', { id: 'env-manual', source: 'manual' })).resolves.toBeNull()
|
||||
expect(state).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says a suspended VM is asleep on a host that cannot wake it', async () => {
|
||||
registerRecipeVmWaker(null)
|
||||
recordVm('suspended')
|
||||
await expect(codeOf('wake')).resolves.toBe('runtime_environment_asleep')
|
||||
})
|
||||
})
|
||||
|
||||
-29
@@ -43,35 +43,6 @@ afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('removes a managed entry through forgetRedundant while another entry manages the server', async () => {
|
||||
const environment = addManaged('managed', 'ssh-1', 46_768, 'public-key')
|
||||
addManaged('managed-alias', 'ssh-2', 46_769, 'public-key')
|
||||
forgetRedundant.mockResolvedValueOnce({
|
||||
outcome: 'forgotten',
|
||||
verdict: 'unverifiable',
|
||||
environmentId: 'managed',
|
||||
sshTargetId: 'ssh-1'
|
||||
})
|
||||
await expect(removeRedundantManagedPairing(userDataPath, environment)).resolves.toMatchObject({
|
||||
removed: { id: 'managed' }
|
||||
})
|
||||
expect(forgetRedundant).toHaveBeenCalledWith('managed')
|
||||
})
|
||||
|
||||
it('surfaces a refusal instead of reporting the entry removed', async () => {
|
||||
const environment = addManaged('managed', 'ssh-1', 46_768, 'public-key')
|
||||
addManaged('managed-alias', 'ssh-2', 46_769, 'public-key')
|
||||
forgetRedundant.mockResolvedValueOnce({
|
||||
outcome: 'refused',
|
||||
verdict: 'live',
|
||||
code: 'orcad_forget_no_managed_twin',
|
||||
reason: 'No other saved entry manages this server anymore.'
|
||||
})
|
||||
await expect(removeRedundantManagedPairing(userDataPath, environment)).rejects.toThrow(
|
||||
'No other saved entry manages this server anymore.'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the pairing to the store guard when the other entry manages a different server', () => {
|
||||
const environment = addManaged('managed', 'ssh-1', 46_768, 'public-key')
|
||||
addManaged('other', 'ssh-2', 46_769, 'other-key')
|
||||
|
||||
@@ -55,12 +55,4 @@ describe('a request whose own recipe VM wake re-paired its server', () => {
|
||||
}
|
||||
expect((await fence(1, 'before')).failure).toMatchObject({ ok: false })
|
||||
})
|
||||
|
||||
it('holds a request that woke nothing to the current pairing', async () => {
|
||||
prepared.current = { environment: AFTER_WAKE, before: AFTER_WAKE, wokenPairingRevision: null }
|
||||
await expect(fence(2, 'after')).resolves.toMatchObject({
|
||||
failure: null,
|
||||
expected: { pairingRevision: 2, runtimeId: 'after' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
// #36923: the desktop's own calls and subscriptions tell the server preparation which request
|
||||
// needs the server (only one that opens a workspace may wake a sleeping recipe VM) and hand it
|
||||
// their abort signal, so a caller that gives up stops waiting on a wake.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveManaged: vi.fn(
|
||||
async (
|
||||
_userDataPath: string,
|
||||
id: string,
|
||||
_preparation?: unknown,
|
||||
_method?: string,
|
||||
_signal?: AbortSignal
|
||||
) => ({ id, pairingRevision: 1 })
|
||||
),
|
||||
// The server comes back re-paired, so the caller's pairing fence ends the call right after
|
||||
// its preparation: only what preparation was handed is under test.
|
||||
prepareRequest: vi.fn(
|
||||
async (
|
||||
_userDataPath: string,
|
||||
id: string,
|
||||
_preparation?: unknown,
|
||||
_method?: string,
|
||||
_signal?: AbortSignal
|
||||
) => ({
|
||||
environment: { id, pairingRevision: 2, createdAt: 0 },
|
||||
before: { id, pairingRevision: 2, createdAt: 0 },
|
||||
wokenPairingRevision: null
|
||||
})
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => '/user-data' } }))
|
||||
vi.mock('../../../shared/remote-runtime/host-store/runtime-environment-store', () => ({
|
||||
resolveEnvironment: (_userDataPath: string, id: string) => ({ id, pairingRevision: 1 }),
|
||||
markEnvironmentUsed: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../shared/remote-runtime/host-catalog/runtime-environments', () => ({
|
||||
getPreferredPairedRuntimeConnection: () => ({ endpoint: 'ws://vm:6768' })
|
||||
}))
|
||||
vi.mock('./runtime-environment-managed-tunnel', () => ({
|
||||
resolveManagedRuntimeEnvironment: mocks.resolveManaged,
|
||||
prepareManagedRuntimeRequest: mocks.prepareRequest,
|
||||
requireWslHostWithoutStart: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../shared/remote-runtime/remote-runtime-client', () => ({
|
||||
subscribeRemoteRuntimeRequest: vi.fn(async () => ({ close: vi.fn() }))
|
||||
}))
|
||||
vi.mock('./runtime-environment-support-routing', () => ({
|
||||
shouldRouteSubscriptionBySupport: () => false
|
||||
}))
|
||||
|
||||
import {
|
||||
callRuntimeEnvironment,
|
||||
subscribeRuntimeEnvironment
|
||||
} from './runtime-environment-transport-routing'
|
||||
|
||||
const callbacks = { onEvent: vi.fn(), onClose: vi.fn() }
|
||||
|
||||
describe('runtime environment transport and a sleeping recipe VM', () => {
|
||||
beforeEach(() => {
|
||||
mocks.resolveManaged.mockClear()
|
||||
mocks.prepareRequest.mockClear()
|
||||
})
|
||||
|
||||
it.each(['worktree.activate', 'worktree.ps'])(
|
||||
'prepares the server for a %s call with its method and signal',
|
||||
async (method) => {
|
||||
const signal = new AbortController().signal
|
||||
const response = await callRuntimeEnvironment(
|
||||
'/user-data',
|
||||
'env-vm',
|
||||
method,
|
||||
{},
|
||||
1000,
|
||||
1,
|
||||
undefined,
|
||||
{ preparation: { wslStart: 'never' }, signal }
|
||||
)
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'runtime_environment_changed' } })
|
||||
expect(mocks.prepareRequest).toHaveBeenCalledWith(
|
||||
'/user-data',
|
||||
'env-vm',
|
||||
{ wslStart: 'never' },
|
||||
method,
|
||||
signal
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('prepares the server for a subscription with its method and signal', async () => {
|
||||
const signal = new AbortController().signal
|
||||
await subscribeRuntimeEnvironment(
|
||||
'/user-data',
|
||||
'env-vm',
|
||||
'runtime.clientEvents.subscribe',
|
||||
undefined,
|
||||
1000,
|
||||
callbacks,
|
||||
() => true,
|
||||
signal
|
||||
)
|
||||
expect(mocks.resolveManaged).toHaveBeenCalledWith(
|
||||
'/user-data',
|
||||
'env-vm',
|
||||
undefined,
|
||||
'runtime.clientEvents.subscribe',
|
||||
signal
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,100 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
fromWebContentsMock,
|
||||
getSpeechModelManagerMock,
|
||||
getSpeechSttServiceMock,
|
||||
deleteLocalSpeechModelMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
fromWebContentsMock: vi.fn(),
|
||||
getSpeechModelManagerMock: vi.fn(),
|
||||
getSpeechSttServiceMock: vi.fn(),
|
||||
deleteLocalSpeechModelMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => '/tmp/orca-speech-test') },
|
||||
BrowserWindow: { fromWebContents: fromWebContentsMock },
|
||||
ipcMain: { handle: handleMock, on: vi.fn() },
|
||||
safeStorage: {
|
||||
decryptString: vi.fn(),
|
||||
encryptString: vi.fn(() => Buffer.from('encrypted')),
|
||||
isEncryptionAvailable: vi.fn(() => true)
|
||||
},
|
||||
systemPreferences: {
|
||||
getMediaAccessStatus: vi.fn(() => 'granted'),
|
||||
askForMediaAccess: vi.fn(() => Promise.resolve(true))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../speech/model-catalog', () => ({
|
||||
SPEECH_MODEL_CATALOG: [],
|
||||
getCatalogModel: vi.fn(() => ({ id: 'model-1' }))
|
||||
}))
|
||||
|
||||
vi.mock('../speech/speech-runtime-service', () => ({
|
||||
getSpeechModelManager: getSpeechModelManagerMock,
|
||||
getSpeechSttService: getSpeechSttServiceMock
|
||||
}))
|
||||
|
||||
vi.mock('../speech/speech-model-deletion', () => ({
|
||||
deleteLocalSpeechModel: deleteLocalSpeechModelMock
|
||||
}))
|
||||
|
||||
import { registerSpeechHandlers } from './speech'
|
||||
|
||||
type SpeechDownloadHandler = (event: { sender: { id: number } }, modelId: string) => Promise<void>
|
||||
|
||||
function getHandler(channel: string): SpeechDownloadHandler {
|
||||
const call = handleMock.mock.calls.find((entry) => entry[0] === channel)
|
||||
if (!call) {
|
||||
throw new Error(`${channel} handler not registered`)
|
||||
}
|
||||
return call[1] as SpeechDownloadHandler
|
||||
}
|
||||
|
||||
describe('registerSpeechHandlers', () => {
|
||||
beforeEach(() => {
|
||||
handleMock.mockReset()
|
||||
fromWebContentsMock.mockReset()
|
||||
getSpeechModelManagerMock.mockReset()
|
||||
getSpeechSttServiceMock.mockReset()
|
||||
deleteLocalSpeechModelMock.mockReset()
|
||||
})
|
||||
|
||||
it('clears the model download progress callback when the window closes', async () => {
|
||||
const clearProgressCallback = vi.fn()
|
||||
let resolveDownload: () => void = () => {}
|
||||
const closeHandlers: (() => void)[] = []
|
||||
const manager = {
|
||||
setProgressCallback: vi.fn(() => clearProgressCallback),
|
||||
downloadModel: vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveDownload = resolve
|
||||
})
|
||||
)
|
||||
}
|
||||
const window = {
|
||||
isDestroyed: vi.fn(() => false),
|
||||
webContents: { send: vi.fn() },
|
||||
once: vi.fn((_event: string, handler: () => void) => {
|
||||
closeHandlers.push(handler)
|
||||
}),
|
||||
off: vi.fn()
|
||||
}
|
||||
getSpeechModelManagerMock.mockReturnValue(manager)
|
||||
fromWebContentsMock.mockReturnValue(window)
|
||||
registerSpeechHandlers({} as never)
|
||||
|
||||
const pending = getHandler('speech:downloadModel')({ sender: { id: 7 } }, 'model-1')
|
||||
closeHandlers[0]?.()
|
||||
resolveDownload()
|
||||
await pending
|
||||
|
||||
expect(clearProgressCallback).toHaveBeenCalledTimes(1)
|
||||
expect(window.off).toHaveBeenCalledWith('closed', expect.any(Function))
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
// #37949: every removal of a saved SSH target (Settings, `orca host rm`, a paired client, a recipe
|
||||
// VM's cleanup) ends in removeRegisteredSshTarget, so the window's cleanup must happen there.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const settings: { hostSettingOverrides?: Record<string, Record<string, string>> } = {}
|
||||
return {
|
||||
removeTarget: vi.fn(),
|
||||
send: vi.fn(),
|
||||
settings,
|
||||
updateSettings: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../ssh/targets/ssh-target-registry', () => ({
|
||||
getSshTargetRegistryStore: () => ({ removeTarget: mocks.removeTarget })
|
||||
}))
|
||||
vi.mock('./ssh-ipc-context', () => ({
|
||||
getCurrentMainWindow: () => ({ isDestroyed: () => false, webContents: { send: mocks.send } }),
|
||||
connectionManager: { disconnect: vi.fn(async () => {}) },
|
||||
portForwardManager: null,
|
||||
persistedStore: {
|
||||
getSettings: () => mocks.settings,
|
||||
updateSettings: mocks.updateSettings,
|
||||
removeSshRemotePtyLeases: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('./ssh-target-lifecycle-queue', () => ({
|
||||
runTargetLifecycle: (_targetId: string, run: () => Promise<void>) => run()
|
||||
}))
|
||||
vi.mock('../../managed-server/lifecycle/orcad-managed-tunnel', () => ({
|
||||
closeOrcadManagedTunnelsForTarget: vi.fn(async () => {})
|
||||
}))
|
||||
vi.mock('../../browser/routing/local-host-partitions/local-ssh-browser-partitions', () => ({
|
||||
releaseLocalSshBrowserPartitionsForTarget: vi.fn(async () => {})
|
||||
}))
|
||||
vi.mock('../../browser/routing/partitions/browser-route-partition-storage-runtime', () => ({
|
||||
clearBrowserRoutePartitionStorageForLocalSshTarget: vi.fn(async () => {})
|
||||
}))
|
||||
|
||||
const { removeRegisteredSshTarget } = await import('./ssh-session-teardown')
|
||||
|
||||
describe('removing a saved SSH target', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.settings.hostSettingOverrides = {
|
||||
'ssh:ssh-1': { displayLabel: 'Renamed', defaultWorktreeLocation: '/srv' },
|
||||
'ssh:ssh-2': { displayLabel: 'Other' }
|
||||
}
|
||||
})
|
||||
|
||||
it("tells the window and drops the host's rename after the target is gone", async () => {
|
||||
await removeRegisteredSshTarget('ssh-1')
|
||||
|
||||
expect(mocks.removeTarget).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mocks.send).toHaveBeenCalledWith('ssh:target-removed', { targetId: 'ssh-1' })
|
||||
expect(mocks.removeTarget.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.send.mock.invocationCallOrder[0]!
|
||||
)
|
||||
expect(mocks.updateSettings).toHaveBeenCalledWith(
|
||||
{
|
||||
hostSettingOverrides: {
|
||||
'ssh:ssh-1': { defaultWorktreeLocation: '/srv' },
|
||||
'ssh:ssh-2': { displayLabel: 'Other' }
|
||||
}
|
||||
},
|
||||
{ notifyListeners: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { parse as parseToml } from 'smol-toml'
|
||||
@@ -17,9 +17,7 @@ import { JcodeHookService } from './hook-service'
|
||||
import {
|
||||
getJcodeConfigPath,
|
||||
getJcodeManagedCommand,
|
||||
getJcodeManagedScriptFileName,
|
||||
getJcodeManagedScriptPath,
|
||||
JCODE_HOOK_EVENTS
|
||||
getJcodeManagedScriptPath
|
||||
} from './hook-settings'
|
||||
import { tomlQuoteString } from './hook-config'
|
||||
|
||||
@@ -39,36 +37,6 @@ describe('JcodeHookService', () => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reports not_installed before any install', () => {
|
||||
const status = new JcodeHookService().getStatus()
|
||||
expect(status).toMatchObject({
|
||||
agent: 'jcode',
|
||||
state: 'not_installed',
|
||||
configPath: getJcodeConfigPath()
|
||||
})
|
||||
})
|
||||
|
||||
it('installs managed hooks into jcode config.toml and posts to /hook/jcode', () => {
|
||||
const status = new JcodeHookService().install()
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.agent).toBe('jcode')
|
||||
expect(status.configPath).toBe(getJcodeConfigPath())
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
|
||||
const config = readFileSync(getJcodeConfigPath(), 'utf8')
|
||||
for (const event of JCODE_HOOK_EVENTS) {
|
||||
// Commands are shell-quoted for jcode, then TOML-quoted for config storage.
|
||||
expect(config).toContain(
|
||||
`${event} = ${tomlQuoteString(getJcodeManagedCommand(getJcodeManagedScriptPath()))}`
|
||||
)
|
||||
}
|
||||
const script = readFileSync(getJcodeManagedScriptPath(), 'utf8')
|
||||
expect(script).toContain('/hook/jcode')
|
||||
expect(script).toContain('payload@-')
|
||||
expect(script).toContain('$JCODE_HOOK_PAYLOAD')
|
||||
expect(script).toContain('payload="$JCODE_HOOK_PAYLOAD"')
|
||||
})
|
||||
|
||||
it('reports a missing managed script and repairs it without changing config', () => {
|
||||
const service = new JcodeHookService()
|
||||
service.install()
|
||||
@@ -86,30 +54,6 @@ describe('JcodeHookService', () => {
|
||||
expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config)
|
||||
})
|
||||
|
||||
it('reports missing scripts alongside incomplete event coverage and user hooks', () => {
|
||||
const service = new JcodeHookService()
|
||||
service.install()
|
||||
writeFileSync(
|
||||
getJcodeConfigPath(),
|
||||
`[hooks]\nsession_start = ${tomlQuoteString(getJcodeManagedCommand(getJcodeManagedScriptPath()))}\nturn_end = "~/bin/my-turn-notify"\n`,
|
||||
'utf8'
|
||||
)
|
||||
rmSync(getJcodeManagedScriptPath())
|
||||
|
||||
const status = service.getStatus()
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.detail).toContain('Managed hook script missing')
|
||||
expect(status.detail).toContain(
|
||||
'Managed hook missing for events: turn_start, pre_tool, post_tool, session_end'
|
||||
)
|
||||
expect(status.detail).toContain('User-owned hooks kept for events: turn_end')
|
||||
expect(service.install().state).toBe('partial')
|
||||
expect(service.getStatus().detail).not.toContain('script missing')
|
||||
expect(readFileSync(getJcodeConfigPath(), 'utf8')).toContain(
|
||||
'turn_end = "~/bin/my-turn-notify"'
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/Users/previous/.orca/agent-hooks/jcode-hook.sh',
|
||||
'C:\\Users\\previous\\.orca\\agent-hooks\\jcode-hook.cmd'
|
||||
@@ -133,60 +77,6 @@ describe('JcodeHookService', () => {
|
||||
expect(readFileSync(configPath, 'utf8')).not.toContain('previous')
|
||||
})
|
||||
|
||||
it('does not report stale managed commands as installed beside the current one', () => {
|
||||
const service = new JcodeHookService()
|
||||
service.install()
|
||||
const staleCommand = getJcodeManagedCommand('/Users/previous/.orca/agent-hooks/jcode-hook.sh')
|
||||
writeFileSync(
|
||||
getJcodeConfigPath(),
|
||||
`[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = [${tomlQuoteString(staleCommand)}, ${tomlQuoteString(getJcodeManagedCommand(getJcodeManagedScriptPath()))}]`).join('\n')}\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const status = service.getStatus()
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
expect(status.detail).toBe(
|
||||
`Managed hook command outdated for events: ${JCODE_HOOK_EVENTS.join(', ')}`
|
||||
)
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect(readFileSync(getJcodeConfigPath(), 'utf8')).not.toContain('previous')
|
||||
})
|
||||
|
||||
it('reports legacy unquoted commands as outdated even when the script exists', () => {
|
||||
const service = new JcodeHookService()
|
||||
service.install()
|
||||
writeFileSync(
|
||||
getJcodeConfigPath(),
|
||||
`[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(getJcodeManagedScriptPath())}`).join('\n')}\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const status = service.getStatus()
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.detail).toContain('Managed hook command outdated for events:')
|
||||
expect(status.detail).not.toContain('script missing')
|
||||
expect(status.detail).not.toContain('User-owned')
|
||||
expect(service.install().state).toBe('installed')
|
||||
})
|
||||
|
||||
it('does not mistake a user command mentioning the managed script in a comment for Orca ownership', () => {
|
||||
const configPath = getJcodeConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`[hooks]\nturn_end = "~/bin/my-turn-notify" # replaces agent-hooks/${getJcodeManagedScriptFileName()}\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const status = new JcodeHookService().getStatus()
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.managedHooksPresent).toBe(false)
|
||||
expect(status.detail).toContain('User-owned hooks kept for events: turn_end')
|
||||
expect(status.detail).not.toContain('command outdated')
|
||||
expect(status.detail).not.toContain('script missing')
|
||||
})
|
||||
|
||||
it('preserves unrelated config tables when installing hooks', () => {
|
||||
const configPath = getJcodeConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
@@ -207,15 +97,6 @@ describe('JcodeHookService', () => {
|
||||
expect(readFileSync(configPath, 'utf8')).toContain('turn_end = "~/bin/my-turn-notify"')
|
||||
})
|
||||
|
||||
it('reports error when the [hooks] table holds a non-scalar value', () => {
|
||||
const configPath = getJcodeConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(configPath, '[hooks]\nturn_end = """\nmultiline\n"""\n', 'utf8')
|
||||
const status = new JcodeHookService().install()
|
||||
expect(status.state).toBe('error')
|
||||
expect(status.detail).toContain('config.toml')
|
||||
})
|
||||
|
||||
it('appends to user hook arrays and removes only its own element', () => {
|
||||
const configPath = getJcodeConfigPath()
|
||||
const source = `[hooks]\npre_tool = [\n "~/gates/pre-tool.sh", # policy\n "~/gates/memory-gate.sh",\n]\npre_tool_timeout_ms = 5000\n`
|
||||
@@ -244,16 +125,4 @@ describe('JcodeHookService', () => {
|
||||
'pre_tool = [\n "~/gates/pre-tool.sh", # policy\n "~/gates/memory-gate.sh",\n]\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('remove clears only the managed entries', () => {
|
||||
new JcodeHookService().install()
|
||||
const before = readFileSync(getJcodeConfigPath(), 'utf8')
|
||||
expect(before).toContain('turn_end')
|
||||
const status = new JcodeHookService().remove()
|
||||
expect(status.state).toBe('not_installed')
|
||||
expect(status.detail).toBeNull()
|
||||
expect(existsSync(getJcodeManagedScriptPath())).toBe(true)
|
||||
const after = readFileSync(getJcodeConfigPath(), 'utf8')
|
||||
expect(after).not.toContain(getJcodeManagedScriptPath())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -157,35 +157,6 @@ describe('Jira client credential storage', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sends a non-browser User-Agent on Jira POST requests', async () => {
|
||||
// Electron's Chrome UA trips Atlassian XSRF checks on token-authenticated writes.
|
||||
const siteId = 'site-alpha'
|
||||
writeJiraFiles(siteId, 'token-alpha')
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ issues: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
const jira = await loadClientModule({ encryptionAvailable: true })
|
||||
const client = jira.getClients(siteId)[0]
|
||||
|
||||
if (!client) {
|
||||
throw new Error('Expected stored Jira client')
|
||||
}
|
||||
|
||||
await jira.jiraRequest(client, '/rest/api/3/search/jql', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ jql: 'project = ALP' })
|
||||
})
|
||||
|
||||
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
const userAgent = headers.get('User-Agent') ?? ''
|
||||
expect(netFetchMock.mock.calls[0]?.[1]?.method).toBe('POST')
|
||||
expect(userAgent).toBe('Orca')
|
||||
expect(userAgent).not.toMatch(/Mozilla|Chrome|Safari|AppleWebKit/i)
|
||||
})
|
||||
|
||||
it('removes cancelled queued reads so the request pool recovers', async () => {
|
||||
const jira = await loadClientModule()
|
||||
await Promise.all([jira.acquire(), jira.acquire(), jira.acquire(), jira.acquire()])
|
||||
@@ -351,21 +322,6 @@ describe('Jira client credential storage', () => {
|
||||
expect(jira.getStatus().credentialError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats empty Jira token files as missing credentials', async () => {
|
||||
const siteId = 'site-alpha'
|
||||
writeJiraFiles(siteId, Buffer.alloc(0))
|
||||
const jira = await loadClientModule({ encryptionAvailable: false })
|
||||
|
||||
await expect(jira.testConnection(siteId)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'Not connected to Jira.',
|
||||
errorCode: 'jira.notConnected'
|
||||
})
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(jira.getStatus()).toMatchObject({ connected: false })
|
||||
})
|
||||
|
||||
it('keeps healthy sites under the "all" selection when one site cannot be decrypted', async () => {
|
||||
writeMultiSiteFiles(
|
||||
[
|
||||
@@ -389,24 +345,6 @@ describe('Jira client credential storage', () => {
|
||||
expect(jira.getStatus().credentialError).toContain('Could not decrypt')
|
||||
})
|
||||
|
||||
it('rethrows the decrypt error for a specific site selection', async () => {
|
||||
writeMultiSiteFiles(
|
||||
[
|
||||
{ id: 'good', token: 'token-good' },
|
||||
{ id: 'bad', token: Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]) }
|
||||
],
|
||||
'bad'
|
||||
)
|
||||
const jira = await loadClientModule({
|
||||
encryptionAvailable: true,
|
||||
decryptString: () => {
|
||||
throw new Error('userCanceledErr')
|
||||
}
|
||||
})
|
||||
|
||||
expect(() => jira.getClients('bad')).toThrow('Could not decrypt')
|
||||
})
|
||||
|
||||
it('does not clear credentials when Electron transport fails after a network change', async () => {
|
||||
const siteId = 'site-alpha'
|
||||
const tokenPath = tokenPathForSite(siteId)
|
||||
@@ -432,156 +370,6 @@ describe('Jira client credential storage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat Jira permission failures as credential revocation', async () => {
|
||||
const jira = await loadClientModule()
|
||||
|
||||
expect(jira.isAuthError(new jira.JiraApiError('Unauthorized', 401))).toBe(true)
|
||||
expect(jira.isAuthError(new jira.JiraApiError('Forbidden', 403))).toBe(false)
|
||||
})
|
||||
|
||||
it('connects to self-hosted Jira with a Bearer PAT against REST v2', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
name: 'wquintal',
|
||||
key: 'JIRAUSER10101',
|
||||
displayName: 'William',
|
||||
emailAddress: 'william@example.com'
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(
|
||||
jira.connect({
|
||||
siteUrl: 'jira.example.com',
|
||||
email: '',
|
||||
apiToken: 'pat-token',
|
||||
authType: 'server'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
// Server /myself has no accountId; the username stands in for it.
|
||||
viewer: { displayName: 'William', accountId: 'wquintal' }
|
||||
})
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://jira.example.com/rest/api/2/myself',
|
||||
expect.objectContaining({ headers: expect.any(Headers) })
|
||||
)
|
||||
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(headers.get('Authorization')).toBe('Bearer pat-token')
|
||||
})
|
||||
|
||||
it('connects to self-hosted Jira with Basic username/password against REST v2', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ name: 'jdoe', key: 'JIRAUSER20202', displayName: 'Jane Doe' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(
|
||||
jira.connect({
|
||||
siteUrl: 'jira.example.com',
|
||||
// Username selects classic Server/DC Basic auth; the token slot holds the password.
|
||||
email: 'jdoe',
|
||||
apiToken: 'account-password',
|
||||
authType: 'server'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
viewer: { displayName: 'Jane Doe', accountId: 'jdoe' }
|
||||
})
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://jira.example.com/rest/api/2/myself',
|
||||
expect.objectContaining({ headers: expect.any(Headers) })
|
||||
)
|
||||
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(headers.get('Authorization')).toBe(
|
||||
`Basic ${Buffer.from('jdoe:account-password').toString('base64')}`
|
||||
)
|
||||
})
|
||||
|
||||
it('uses Basic auth for stored self-hosted sites that carry a username', async () => {
|
||||
const siteId = 'site-server-basic'
|
||||
const orcaDir = join(tempHome, '.orca')
|
||||
mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(orcaDir, 'jira-sites.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeSiteId: siteId,
|
||||
selectedSiteId: siteId,
|
||||
sites: [
|
||||
{
|
||||
id: siteId,
|
||||
siteUrl: 'https://jira.example.com',
|
||||
email: 'jdoe',
|
||||
displayName: 'Jane Doe',
|
||||
accountId: 'jdoe',
|
||||
authType: 'server'
|
||||
}
|
||||
]
|
||||
}),
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
writeFileSync(tokenPathForSite(siteId), 'account-password')
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ name: 'jdoe', displayName: 'Jane Doe' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(jira.testConnection(siteId)).resolves.toMatchObject({ ok: true })
|
||||
|
||||
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(headers.get('Authorization')).toBe(
|
||||
`Basic ${Buffer.from('jdoe:account-password').toString('base64')}`
|
||||
)
|
||||
})
|
||||
|
||||
it('requires a token but not an email for self-hosted connections', async () => {
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(
|
||||
jira.connect({
|
||||
siteUrl: 'jira.example.com',
|
||||
email: '',
|
||||
apiToken: '',
|
||||
authType: 'server'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'Personal access token is required.',
|
||||
errorCode: 'jira.personalTokenRequired'
|
||||
})
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires a password when a username is present on self-hosted', async () => {
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(
|
||||
jira.connect({
|
||||
siteUrl: 'jira.example.com',
|
||||
email: 'jdoe',
|
||||
apiToken: '',
|
||||
authType: 'server'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'Password is required.',
|
||||
errorCode: 'jira.passwordRequired'
|
||||
})
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps distinct self-hosted PAT accounts on one host as separate sites', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(
|
||||
@@ -620,79 +408,6 @@ describe('Jira client credential storage', () => {
|
||||
expect(stored.sites).toHaveLength(2)
|
||||
expect(stored.sites.map((site) => site.accountId).sort()).toEqual(['alice', 'bot'])
|
||||
})
|
||||
|
||||
it('uses Bearer auth and REST v2 for stored self-hosted sites', async () => {
|
||||
const siteId = 'site-server'
|
||||
const orcaDir = join(tempHome, '.orca')
|
||||
mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(orcaDir, 'jira-sites.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeSiteId: siteId,
|
||||
selectedSiteId: siteId,
|
||||
sites: [
|
||||
{
|
||||
id: siteId,
|
||||
siteUrl: 'https://jira.example.com',
|
||||
email: '',
|
||||
displayName: 'William',
|
||||
accountId: 'wquintal',
|
||||
authType: 'server'
|
||||
}
|
||||
]
|
||||
}),
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
writeFileSync(tokenPathForSite(siteId), 'pat-token')
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ name: 'wquintal', displayName: 'William' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(jira.testConnection(siteId)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
viewer: { displayName: 'William' }
|
||||
})
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://jira.example.com/rest/api/2/myself',
|
||||
expect.objectContaining({ headers: expect.any(Headers) })
|
||||
)
|
||||
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(headers.get('Authorization')).toBe('Bearer pat-token')
|
||||
})
|
||||
|
||||
it('bridges proxy environment settings before Jira connect requests', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
accountId: 'account-alpha',
|
||||
displayName: 'Ada',
|
||||
emailAddress: 'ada@example.com'
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
const jira = await loadClientModule()
|
||||
|
||||
await expect(
|
||||
jira.connect({
|
||||
siteUrl: 'example.atlassian.net',
|
||||
email: 'ada@example.com',
|
||||
apiToken: 'token-alpha'
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, viewer: { displayName: 'Ada' } })
|
||||
|
||||
expect(resolveProxyMock).toHaveBeenCalledWith('https://example.atlassian.net/rest/api/3/myself')
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(headers.get('User-Agent')).toBe('Orca')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Jira site file cross-process visibility', () => {
|
||||
@@ -813,18 +528,4 @@ describe('Jira site file cross-process visibility', () => {
|
||||
|
||||
expect(jira.getStatus()).toMatchObject({ connected: false, sites: [] })
|
||||
})
|
||||
|
||||
it('follows the selected site changing underneath it', async () => {
|
||||
const sites = [
|
||||
{ id: 'site-alpha', token: 'token-alpha' },
|
||||
{ id: 'site-beta', token: 'token-beta' }
|
||||
]
|
||||
writeMultiSiteFiles(sites, 'site-alpha')
|
||||
const jira = await loadClientModule()
|
||||
expect(jira.getStatus()).toMatchObject({ selectedSiteId: 'site-alpha' })
|
||||
|
||||
writeMultiSiteFiles(sites, 'site-beta')
|
||||
|
||||
expect(jira.getStatus()).toMatchObject({ selectedSiteId: 'site-beta' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture'
|
||||
import { isPlainObject } from '../agent-hooks/installer-utils'
|
||||
import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command'
|
||||
import { KiroHookService } from './hook-service'
|
||||
import { getKiroManagedCommandMatcher, KIRO_HOOK_EVENTS } from './hook-settings'
|
||||
|
||||
@@ -45,13 +44,6 @@ afterEach(() => {
|
||||
|
||||
const agentsDir = (): string => join(home, '.kiro', 'agents')
|
||||
const agentPath = (name: string): string => join(agentsDir(), `${name}.json`)
|
||||
const scriptPath = (): string =>
|
||||
join(
|
||||
home,
|
||||
'.orca',
|
||||
'agent-hooks',
|
||||
process.platform === 'win32' ? 'kiro-hook.cmd' : 'kiro-hook.sh'
|
||||
)
|
||||
|
||||
function writeAgent(name: string, config: unknown): void {
|
||||
mkdirSync(agentsDir(), { recursive: true })
|
||||
@@ -65,15 +57,6 @@ function readAgent(name: string): Record<string, unknown> {
|
||||
const USER_STOP_HOOK = { command: 'say done' }
|
||||
|
||||
describe('KiroHookService', () => {
|
||||
it("reports the V2 default agent's gap as partial when no custom agents exist", () => {
|
||||
const result = new KiroHookService().install()
|
||||
expect(result.state).toBe('partial')
|
||||
expect(result.detail).toContain('kiro-cli agent create')
|
||||
expect(result.detail).toContain(agentsDir())
|
||||
// The launcher is still laid down so the refresher can keep it current.
|
||||
expect(existsSync(scriptPath())).toBe(true)
|
||||
})
|
||||
|
||||
it('adds one managed hook per event to every agent and keeps user hooks', () => {
|
||||
writeAgent('caverna', { name: 'caverna', tools: ['*'], hooks: { stop: [USER_STOP_HOOK] } })
|
||||
writeAgent('reviewer', { name: 'reviewer', tools: ['read'] })
|
||||
@@ -118,29 +101,6 @@ describe('KiroHookService', () => {
|
||||
expect(readFileSync(agentPath('broken'), 'utf-8')).toBe('{ not json')
|
||||
})
|
||||
|
||||
it('reports partial when an agent created after install has no hooks yet', () => {
|
||||
writeAgent('first', { name: 'first' })
|
||||
const service = new KiroHookService()
|
||||
service.install()
|
||||
writeAgent('later', { name: 'later' })
|
||||
|
||||
const result = service.getStatus()
|
||||
expect(result.state).toBe('partial')
|
||||
expect(result.detail).toContain('later.json has no Orca hooks')
|
||||
})
|
||||
|
||||
it('reports a missing launcher as partial instead of installed', () => {
|
||||
writeAgent('tracked', { name: 'tracked' })
|
||||
const service = new KiroHookService()
|
||||
service.install()
|
||||
rmSync(scriptPath())
|
||||
expect(service.getStatus()).toMatchObject({
|
||||
state: 'partial',
|
||||
managedHooksPresent: true,
|
||||
detail: expect.stringContaining('script missing')
|
||||
})
|
||||
})
|
||||
|
||||
// Shape `kiro-cli agent upgrade` (2.29+) writes; edits to `hooks` keep the file "universal".
|
||||
it('converges list-form hooks in place and removes only its entries, locally and remotely', async () => {
|
||||
const orcaHook = (trigger: string, command: string) => ({
|
||||
@@ -231,15 +191,6 @@ describe('KiroHookService', () => {
|
||||
expect(readAgent('plain')).toEqual({ name: 'plain' })
|
||||
})
|
||||
|
||||
it('patches the agents under $KIRO_HOME instead of ~/.kiro', () => {
|
||||
const kiroHome = join(home, 'custom-kiro')
|
||||
mkdirSync(join(kiroHome, 'agents'), { recursive: true })
|
||||
writeFileSync(join(kiroHome, 'agents', 'relocated.json'), JSON.stringify({ name: 'relocated' }))
|
||||
process.env.KIRO_HOME = kiroHome
|
||||
expect(new KiroHookService().install().state).toBe('installed')
|
||||
expect(existsSync(agentsDir())).toBe(false)
|
||||
})
|
||||
|
||||
it('patches the remote agents under the probed KIRO_HOME, not ~/.kiro', async () => {
|
||||
const kiroHome = join(home, 'remote-kiro')
|
||||
mkdirSync(join(kiroHome, 'agents'), { recursive: true })
|
||||
@@ -258,67 +209,5 @@ describe('KiroHookService', () => {
|
||||
expect(readAgent('stale').hooks).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to <remoteHome>/.kiro/agents when no KIRO_HOME was probed', async () => {
|
||||
writeAgent('default', { name: 'default' })
|
||||
|
||||
const result = await new KiroHookService().installRemote(
|
||||
createManagedHookLocalFilesystem(),
|
||||
home
|
||||
)
|
||||
|
||||
expect(result.state).toBe('installed')
|
||||
expect(readAgent('default').hooks).toBeDefined()
|
||||
})
|
||||
|
||||
it('reports a remote agent deleted after the listing as unreadable, not invalid JSON', async () => {
|
||||
writeAgent('kept', { name: 'kept' })
|
||||
writeAgent('gone', { name: 'gone' })
|
||||
const sftp = createManagedHookLocalFilesystem()
|
||||
const readFile = sftp.readFile
|
||||
Object.assign(sftp, {
|
||||
readFile: (
|
||||
path: string,
|
||||
encoding: BufferEncoding,
|
||||
callback: (error: Error | undefined, contents: Buffer) => void
|
||||
) => {
|
||||
if (path.endsWith('gone.json')) {
|
||||
rmSync(path)
|
||||
}
|
||||
return readFile(path, encoding, callback)
|
||||
}
|
||||
})
|
||||
|
||||
const result = await new KiroHookService().installRemote(sftp, home)
|
||||
|
||||
expect(result.state).toBe('partial')
|
||||
expect(result.detail).toContain('gone.json is could not be read')
|
||||
expect(result.detail).not.toContain('not valid JSON')
|
||||
expect(readAgent('kept').hooks).toBeDefined()
|
||||
})
|
||||
|
||||
// Check command shapes everywhere; windows-hook-payload-delivery.test.ts executes them under Kiro's shells.
|
||||
it('registers the Windows batch hook command for every event', () => {
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
try {
|
||||
writeAgent('mine', { name: 'mine' })
|
||||
expect(new KiroHookService().install().state).toBe('installed')
|
||||
const hooks = readAgent('mine').hooks
|
||||
if (!isPlainObject(hooks)) {
|
||||
throw new Error('Expected hooks object')
|
||||
}
|
||||
const command = wrapWindowsBatchHookCommand(scriptPath())
|
||||
for (const event of KIRO_HOOK_EVENTS) {
|
||||
expect(hooks[event]).toEqual([expect.objectContaining({ command })])
|
||||
}
|
||||
} finally {
|
||||
platform.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores non-JSON files such as the shipped example config', () => {
|
||||
mkdirSync(agentsDir(), { recursive: true })
|
||||
writeFileSync(join(agentsDir(), 'agent_config.json.example'), '{}')
|
||||
expect(new KiroHookService().install().state).toBe('partial')
|
||||
expect(readFileSync(join(agentsDir(), 'agent_config.json.example'), 'utf-8')).toBe('{}')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -156,21 +156,6 @@ function mkdtempLike(prefix: string): string {
|
||||
}
|
||||
|
||||
describe('Linear client workspace storage', () => {
|
||||
it('forwards an optional detail deadline to both SDK clients', async () => {
|
||||
const linear = await loadClientModule()
|
||||
await linear.connect('token-alpha')
|
||||
const signal = new AbortController().signal
|
||||
const entry = linear.getClients('org-alpha', signal)[0]
|
||||
expect(linearClientMock).toHaveBeenLastCalledWith({ apiKey: 'token-alpha', signal })
|
||||
linear.getPublicFileUrlClient(entry, signal)
|
||||
expect(linearClientMock).toHaveBeenLastCalledWith({
|
||||
apiKey: 'token-alpha',
|
||||
signal,
|
||||
headers: {
|
||||
'public-file-urls-expire-in': String(linear.LINEAR_PUBLIC_FILE_URL_EXPIRY_SECONDS)
|
||||
}
|
||||
})
|
||||
})
|
||||
it('requests temporary file URLs using only the selected workspace credential', async () => {
|
||||
const linear = await loadClientModule()
|
||||
await linear.connect('token-alpha')
|
||||
@@ -214,22 +199,6 @@ describe('Linear client workspace storage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a legacy single-token workspace without constructing a Linear client', async () => {
|
||||
writeLegacyLinearFiles('token-alpha', {
|
||||
displayName: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
organizationName: 'Alpha'
|
||||
})
|
||||
const linear = await loadClientModule()
|
||||
|
||||
expect(linear.getStatus()).toMatchObject({
|
||||
connected: true,
|
||||
selectedWorkspaceId: 'legacy',
|
||||
workspaces: [{ id: 'legacy', organizationName: 'Alpha', isLegacy: true }]
|
||||
})
|
||||
expect(linearClientMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('migrates legacy token storage to a real workspace id when explicitly tested', async () => {
|
||||
writeLegacyLinearFiles('token-alpha', {
|
||||
displayName: 'Ada',
|
||||
@@ -364,24 +333,6 @@ describe('Linear client workspace storage', () => {
|
||||
expect(linear.getStatus().credentialError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats empty Linear token files as missing credentials', async () => {
|
||||
writeLegacyLinearToken(Buffer.alloc(0), {
|
||||
displayName: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
organizationName: 'Alpha'
|
||||
})
|
||||
const linear = await loadClientModule({ encryptionAvailable: false })
|
||||
|
||||
await expect(linear.testConnection('legacy')).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'No API key stored.',
|
||||
errorCode: 'linear.noApiKey'
|
||||
})
|
||||
|
||||
expect(linearClientMock).not.toHaveBeenCalled()
|
||||
expect(linear.getStatus()).toMatchObject({ connected: false })
|
||||
})
|
||||
|
||||
it('keeps healthy workspaces under the "all" selection when one cannot be decrypted', async () => {
|
||||
writeMultiWorkspaceFiles(
|
||||
[
|
||||
@@ -409,24 +360,6 @@ describe('Linear client workspace storage', () => {
|
||||
expect(clients.map((client) => client.workspace.id)).toEqual(['good'])
|
||||
expect(linear.getStatus().credentialError).toContain('Could not decrypt')
|
||||
})
|
||||
|
||||
it('rethrows the decrypt error for a specific workspace selection', async () => {
|
||||
writeMultiWorkspaceFiles(
|
||||
[
|
||||
{ id: 'good', token: 'token-good' },
|
||||
{ id: 'bad', token: Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]) }
|
||||
],
|
||||
'bad'
|
||||
)
|
||||
const linear = await loadClientModule({
|
||||
encryptionAvailable: true,
|
||||
decryptString: () => {
|
||||
throw new Error('userCanceledErr')
|
||||
}
|
||||
})
|
||||
|
||||
expect(() => linear.getClients('bad')).toThrow('Could not decrypt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Linear workspace file cross-process visibility', () => {
|
||||
@@ -548,18 +481,4 @@ describe('Linear workspace file cross-process visibility', () => {
|
||||
activeWorkspaceId: 'ws-alpha'
|
||||
})
|
||||
})
|
||||
|
||||
it('follows the selected workspace changing underneath it', async () => {
|
||||
const workspaces = [
|
||||
{ id: 'ws-alpha', token: 'token-alpha' },
|
||||
{ id: 'ws-beta', token: 'token-beta' }
|
||||
]
|
||||
writeMultiWorkspaceFiles(workspaces, 'ws-alpha')
|
||||
const linear = await loadClientModule()
|
||||
expect(linear.getStatus()).toMatchObject({ selectedWorkspaceId: 'ws-alpha' })
|
||||
|
||||
writeMultiWorkspaceFiles(workspaces, 'ws-beta')
|
||||
|
||||
expect(linear.getStatus()).toMatchObject({ selectedWorkspaceId: 'ws-beta' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,9 +7,7 @@ vi.mock('../ipc/runtime-environments/runtime-environment-transport-routing', ()
|
||||
vi.mock('../ipc/runtime-environments/runtime-environment-registry-root', () => ({
|
||||
getRuntimeEnvironmentRegistryRoot: () => '/user-data'
|
||||
}))
|
||||
|
||||
import {
|
||||
assertRemoteChunkReadsSupported,
|
||||
readRemotePreviewRange,
|
||||
readRemotePreviewChunk,
|
||||
renewRemotePreviewGrant
|
||||
@@ -40,53 +38,7 @@ describe('remote file preview reader', () => {
|
||||
await expect(readRemotePreviewChunk(address, 0, 4, signal)).rejects.toThrow('corrupted')
|
||||
})
|
||||
|
||||
it('proves chunk reads with one byte once per pairing, and re-proves after a re-pair', async () => {
|
||||
callRuntimeEnvironment.mockResolvedValue(
|
||||
ok({ contentBase64: 'AA==', bytesRead: 1, eof: false })
|
||||
)
|
||||
const pinned = { ...address, environmentId: 'env-probe' }
|
||||
await assertRemoteChunkReadsSupported(pinned, signal)
|
||||
await assertRemoteChunkReadsSupported(pinned, signal)
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledTimes(1)
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
'/user-data',
|
||||
'env-probe',
|
||||
'files.readChunk',
|
||||
expect.objectContaining({ offset: 0, length: 1 }),
|
||||
20_000,
|
||||
7,
|
||||
undefined,
|
||||
{ signal }
|
||||
)
|
||||
await assertRemoteChunkReadsSupported(
|
||||
{ ...pinned, expectedEnvironmentPairingRevision: 8 },
|
||||
signal
|
||||
)
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// Why: a host can answer files.readChunk yet lack the grant's paging method.
|
||||
it('pages a granted file through its grant, proven apart from workspace reads', async () => {
|
||||
callRuntimeEnvironment.mockResolvedValue(
|
||||
ok({ contentBase64: 'AA==', bytesRead: 1, eof: false })
|
||||
)
|
||||
const workspace = { ...address, environmentId: 'env-kinds' }
|
||||
const { relativePath: _relativePath, ...base } = workspace
|
||||
const granted = { ...base, grantId: 'g-1', absolutePath: '/tmp/clip.mp4' }
|
||||
await assertRemoteChunkReadsSupported(workspace, signal)
|
||||
await assertRemoteChunkReadsSupported(granted, signal)
|
||||
expect(callRuntimeEnvironment).toHaveBeenLastCalledWith(
|
||||
'/user-data',
|
||||
'env-kinds',
|
||||
'files.readTerminalArtifactChunk',
|
||||
{ worktree: 'id:wt-1', grantId: 'g-1', absolutePath: '/tmp/clip.mp4', offset: 0, length: 1 },
|
||||
20_000,
|
||||
7,
|
||||
undefined,
|
||||
{ signal }
|
||||
)
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('renews a granted file only at the path its first grant named', async () => {
|
||||
const { relativePath: _relativePath, ...base } = address
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { resolveHiddenProbeCwd } from '../../rate-limits/hidden-probe-cwd'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyCodexConfiguredLaunchDefaults } from '../../codex/structured/launch/codex-configured-launch-defaults'
|
||||
import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session/agent-session-wire'
|
||||
import { harness } from './agent-model-catalog-account-list-test-support'
|
||||
|
||||
for (const agent of ['claude', 'codex'] as const) {
|
||||
describe(`${agent} project defaults on the account listing`, () => {
|
||||
it('shows the account’s configured model and effort in every folder, whatever its own settings pick', async () => {
|
||||
// Every folder's settings may pick both; the picker still follows the account.
|
||||
const h = harness(agent, { workspaceMayOverrideDefaultModel: async () => true })
|
||||
const efforts = ['low', 'medium', 'high'].map((value) => ({ value, label: value }))
|
||||
const raw = [
|
||||
{
|
||||
id: h.full[0],
|
||||
label: 'Model',
|
||||
isDefault: true,
|
||||
efforts,
|
||||
defaultEffort: 'medium',
|
||||
declaredDefaultEffort: 'medium'
|
||||
}
|
||||
]
|
||||
h.byFolder[resolveHiddenProbeCwd()] = {
|
||||
origin: 'probe',
|
||||
complete: true,
|
||||
configured: { model: h.full[0], effort: 'high' },
|
||||
// Codex folds configured defaults into rows before the catalog service sees them.
|
||||
models: applyCodexConfiguredLaunchDefaults(raw, { model: h.full[0], effort: 'high' })
|
||||
}
|
||||
await h.service.read({ agent, waitForListing: true })
|
||||
const row = (result: AgentSessionModelCatalogResult) =>
|
||||
result.origin === 'unknown' ? null : result.models[0]
|
||||
for (const id of ['a', 'e']) {
|
||||
expect(row(await h.read(id, { waitForListing: true }))).toMatchObject({
|
||||
isDefault: true,
|
||||
defaultEffort: 'high'
|
||||
})
|
||||
expect(h.service.peek(id)?.configured).toMatchObject({ modelId: h.full[0] })
|
||||
}
|
||||
expect(h.runs).toEqual([resolveHiddenProbeCwd()])
|
||||
})
|
||||
|
||||
it('lists a workspace this host cannot inspect as the account does, never where Orca runs', async () => {
|
||||
const h = harness(agent)
|
||||
const result = await h.service.read({ agent, workspacePath: null, waitForListing: true })
|
||||
expect(result.origin === 'unknown' ? null : result.models).toEqual(
|
||||
h.full.map((id) => expect.objectContaining({ id }))
|
||||
)
|
||||
expect(h.runs).toEqual([resolveHiddenProbeCwd()])
|
||||
expect(h.unresolvedRuns).toEqual([])
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -256,27 +256,6 @@ it('keeps a shared listing for an earlier-ending reader of the same folder when
|
||||
expect(h.run).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a later reader of the same folder waiting for its own deadline, not the first one', async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
const h = sharedListingHarness()
|
||||
void h.service.demand({ agent: 'codex', workspacePath: '/a', deadline: Date.now() + 1_000 })
|
||||
await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce())
|
||||
const later = h.service.read({
|
||||
agent: 'codex',
|
||||
workspacePath: '/a',
|
||||
waitForListing: true,
|
||||
deadline: Date.now() + 60_000
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
h.discovery.resolve({ ...listing('shared'), complete: true, configured: {} })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(await later).toMatchObject({ models: [{ id: 'shared' }] })
|
||||
expect(h.run).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
function readOperation(h: ReturnType<typeof sharedListingHarness>, deadline: number) {
|
||||
const controller = new AbortController()
|
||||
let current = true
|
||||
|
||||
@@ -272,34 +272,6 @@ it('ends a shorter reader at its own deadline, and a stalled refresh lists nothi
|
||||
expect(h.run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([10, 100])(
|
||||
'resolves setup and project settings once for %i readers of one request, before and after it settles',
|
||||
async (readers) => {
|
||||
const h = harness()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
h.folder.gate = gate
|
||||
const reads = Array.from({ length: readers }, () =>
|
||||
h.service.demand({ agent: 'codex', sessionId: 'b', deadline: Date.now() + 30_000 })
|
||||
)
|
||||
gate.resolve()
|
||||
await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce())
|
||||
// More readers arrive while the listing that setup found is still running.
|
||||
reads.push(
|
||||
...Array.from({ length: readers }, () =>
|
||||
h.service.demand({ agent: 'codex', sessionId: 'b', deadline: Date.now() + 30_000 })
|
||||
)
|
||||
)
|
||||
h.discovery.resolve(listing('shared'))
|
||||
await Promise.all(reads)
|
||||
// One request needs one folder lookup, one CLI resolution and one project-settings check.
|
||||
expect(h.recordWorkspacePath).toHaveBeenCalledOnce()
|
||||
// A due listing resolves the CLI twice (a snapshot, then a fresh lookup), once per setup.
|
||||
expect(h.resolve.mock.calls.length).toBeLessThanOrEqual(2)
|
||||
expect(h.workspaceMayOverrideDefaultModel.mock.calls.length).toBeLessThanOrEqual(1)
|
||||
expect(h.run).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('holds readers of a stalled refresh only to their own deadlines, and starts no other until asked', async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => {
|
||||
@@ -331,18 +303,6 @@ it('holds readers of a stalled refresh only to their own deadlines, and starts n
|
||||
expect(await c).toMatchObject({ models: [{ id: 'fresh' }] })
|
||||
})
|
||||
|
||||
it("resolves a moved folder afresh instead of reusing the old folder's setup", async () => {
|
||||
const h = harness()
|
||||
h.folder.cli = Promise.withResolvers<void>()
|
||||
void h.service.demand({ agent: 'codex', sessionId: 'b' })
|
||||
await vi.waitFor(() => expect(h.resolve).toHaveBeenCalledOnce())
|
||||
h.folder.path = '/new'
|
||||
h.service.retireContext('ws')
|
||||
void h.service.demand({ agent: 'codex', sessionId: 'b' })
|
||||
await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce())
|
||||
expect(h.resolve.mock.calls.at(-1)?.[4]).toBe('/new')
|
||||
})
|
||||
|
||||
it.each(['cancelled', 'stopped'] as const)(
|
||||
'settles a reader at once when it is %s while its setup is stalled',
|
||||
async (how) => {
|
||||
@@ -396,22 +356,6 @@ it("keeps the folder's defaults when they arrive after a shorter last reader lef
|
||||
expect(await a).toMatchObject({ models: [{ id: 'listed', isDefault: true }] })
|
||||
})
|
||||
|
||||
it("reuses a settled setup after its starting reader's deadline while its listing still runs", async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
const h = harness(agent)
|
||||
void h.service.demand({ agent, sessionId: 'a', deadline: Date.now() + 1_000 })
|
||||
void h.service.demand({ agent, sessionId: 'b', deadline: Date.now() + 30_000 })
|
||||
await vi.waitFor(() => expect(h.run).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
void h.service.demand({ agent, sessionId: 'b', deadline: Date.now() + 30_000 })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(h.recordWorkspacePath).toHaveBeenCalledOnce()
|
||||
expect(h.workspaceMayOverrideDefaultModel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("keeps the listing when the folder's settings stall, naming no default, within the reader's budget", async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => {
|
||||
|
||||
-143
@@ -442,45 +442,8 @@ describe.each([
|
||||
})
|
||||
|
||||
// Steal focus after resume to exercise the grab’s caret restore; otherwise select-all retries can escape detection.
|
||||
it('restores the caret, not a select-all, when the guest takes the bar back mid-grab', () => {
|
||||
renderWorkspacePane()
|
||||
const staged = startEditing('example.int')
|
||||
act(() => staged.setSelectionRange(3, 7))
|
||||
|
||||
act(() => adoptOntoClient())
|
||||
act(() => flushFrames(1))
|
||||
|
||||
const adopted = addressBar()
|
||||
expect(document.activeElement).toBe(adopted)
|
||||
act(() => webview.focus())
|
||||
expect(document.activeElement).toBe(webview)
|
||||
|
||||
act(() => flushFrames())
|
||||
|
||||
expect(document.activeElement).toBe(adopted)
|
||||
expect(adopted.value).toBe('example.int')
|
||||
expect([adopted.selectionStart, adopted.selectionEnd]).toEqual([3, 7])
|
||||
})
|
||||
|
||||
// An open list survives focus naturally; only a dismissed list proves dropdown state crosses the swap.
|
||||
it('keeps a dismissed suggestion list dismissed through the swap', () => {
|
||||
renderWorkspacePane()
|
||||
const staged = startEditing('example.int')
|
||||
act(() => {
|
||||
fireEvent.keyDown(staged, { key: 'Escape' })
|
||||
})
|
||||
expect(staged.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.activeElement).toBe(staged)
|
||||
|
||||
act(() => adoptOntoClient())
|
||||
act(() => flushFrames())
|
||||
|
||||
const adopted = addressBar()
|
||||
expect(adopted.value).toBe('example.int')
|
||||
expect(document.activeElement).toBe(adopted)
|
||||
expect(adopted.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByRole('option', { name: /Example docs/ })).toBeNull()
|
||||
})
|
||||
|
||||
// Only same-commit swaps may reclaim focus; later worktree revisits must not resume old edits.
|
||||
it('does not resume an edit into a pane that mounts in a later task', async () => {
|
||||
@@ -496,58 +459,9 @@ describe.each([
|
||||
expect(document.activeElement).not.toBe(remounted)
|
||||
})
|
||||
|
||||
it('leaves an idle address bar alone when the same swap happens', () => {
|
||||
renderWorkspacePane()
|
||||
const streamed = addressBar()
|
||||
expect(document.activeElement).not.toBe(streamed)
|
||||
|
||||
act(() => adoptOntoClient())
|
||||
act(() => flushFrames())
|
||||
|
||||
const adopted = addressBar()
|
||||
expect(adopted.value).toBe('')
|
||||
// Adoption without an active edit must not seize chrome focus or open suggestions.
|
||||
expect(document.activeElement).not.toBe(adopted)
|
||||
expect(adopted.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByRole('option', { name: /Example docs/ })).toBeNull()
|
||||
})
|
||||
|
||||
// Generation changes reattach in place; edits must survive without remounting.
|
||||
it('carries a live edit through a host restart that bumps the page generation', () => {
|
||||
renderWorkspacePane()
|
||||
act(() => adoptOntoClient())
|
||||
act(() => flushFrames())
|
||||
|
||||
const before = startEditing('rebooting.internal')
|
||||
act(() => before.setSelectionRange(4, 4))
|
||||
|
||||
act(() => adoptOntoClient(CLIENT_PLACEMENT.pageHostGeneration + 1))
|
||||
act(() => flushFrames())
|
||||
|
||||
const after = addressBar()
|
||||
// The registry can rescue drafts across remounts; node identity is needed to prove the chrome was never rebuilt.
|
||||
expect(after).toBe(before)
|
||||
expect(after.value).toBe('rebooting.internal')
|
||||
expect(document.activeElement).toBe(after)
|
||||
expect([after.selectionStart, after.selectionEnd]).toEqual([4, 4])
|
||||
})
|
||||
|
||||
// Headless adoption stays streamed and must preserve the same live edit.
|
||||
it('keeps a live edit when a headless host adopts the page in place', () => {
|
||||
renderWorkspacePane()
|
||||
const streamed = startEditing('headless.internal')
|
||||
act(() => streamed.setSelectionRange(8, 8))
|
||||
|
||||
act(() => adoptOntoServer())
|
||||
act(() => flushFrames())
|
||||
|
||||
const after = addressBar()
|
||||
expect(screen.getByTestId('streamed-viewport')).not.toBeNull()
|
||||
expect(after).toBe(streamed)
|
||||
expect(after.value).toBe('headless.internal')
|
||||
expect(document.activeElement).toBe(after)
|
||||
expect([after.selectionStart, after.selectionEnd]).toEqual([8, 8])
|
||||
})
|
||||
|
||||
it('holds a URL submitted against the staged page and loads it once the guest attaches', () => {
|
||||
renderWorkspacePane()
|
||||
@@ -567,30 +481,6 @@ describe.each([
|
||||
})
|
||||
|
||||
// Only input identity exposes chrome remounts; save/resume can otherwise hide the repeated dropdown animation.
|
||||
it('adopts a client-hosted page without remounting the chrome', () => {
|
||||
stageClientHostedHandle()
|
||||
renderWorkspacePane()
|
||||
|
||||
// The client-hosted pane mounts from the first frame, so there is no pane to swap at adoption.
|
||||
expect(screen.queryByTestId('streamed-viewport')).toBeNull()
|
||||
// Do not attach before the host publishes the page; the retained registry would reject the unknown ID.
|
||||
expect(mocks.attach).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('Client-hosted browser unavailable')).toBeNull()
|
||||
|
||||
const staged = startEditing('example.int')
|
||||
act(() => staged.setSelectionRange(3, 7))
|
||||
expect(staged.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
act(() => adoptOntoClient())
|
||||
act(() => flushFrames())
|
||||
|
||||
expect(addressBar()).toBe(staged)
|
||||
expect(staged.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(staged.value).toBe('example.int')
|
||||
expect(document.activeElement).toBe(staged)
|
||||
expect([staged.selectionStart, staged.selectionEnd]).toEqual([3, 7])
|
||||
expect(mocks.attach).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Continuity alone passes if nothing happens; verify generation changes really reattach the guest.
|
||||
it('re-attaches the guest when a host restart bumps the page generation', () => {
|
||||
@@ -618,23 +508,6 @@ describe.each([
|
||||
})
|
||||
|
||||
// Do not consume a one-time tour before placement makes its controls usable.
|
||||
it('does not burn the intro tour on a pane that is still connecting', () => {
|
||||
const recordFeatureInteraction = vi.fn(async () => {})
|
||||
useAppStore.setState({
|
||||
persistedUIReady: true,
|
||||
contextualToursSeenIds: ['client-hosted-browser'],
|
||||
recordFeatureInteraction
|
||||
} as unknown as Parameters<typeof useAppStore.setState>[0])
|
||||
stageClientHostedHandle()
|
||||
renderWorkspacePane()
|
||||
|
||||
expect(recordFeatureInteraction).not.toHaveBeenCalled()
|
||||
|
||||
act(() => adoptOntoClient())
|
||||
act(() => flushFrames())
|
||||
|
||||
expect(recordFeatureInteraction).toHaveBeenCalledWith('client-hosted-browser')
|
||||
})
|
||||
|
||||
// Live host status can override cached client placement and swap to streaming; retain edit-session recovery for this path.
|
||||
it('carries a live edit when a staged client-hosted page is adopted onto the server', () => {
|
||||
@@ -656,22 +529,6 @@ describe.each([
|
||||
expect([adopted.selectionStart, adopted.selectionEnd]).toEqual([4, 4])
|
||||
})
|
||||
|
||||
it('holds a URL submitted against a staged client-hosted page until its guest attaches', () => {
|
||||
stageClientHostedHandle()
|
||||
renderWorkspacePane()
|
||||
const staged = startEditing('https://example.internal/client-deferred')
|
||||
act(() => {
|
||||
fireEvent.submit(staged.closest('form') ?? staged)
|
||||
})
|
||||
|
||||
expect(webview.loadURL).not.toHaveBeenCalled()
|
||||
|
||||
act(() => adoptOntoClient())
|
||||
act(() => flushFrames())
|
||||
|
||||
expect(webview.loadURL).toHaveBeenCalledWith('https://example.internal/client-deferred')
|
||||
})
|
||||
|
||||
// Restored client-placed pages must await recovery locally; hosts reject server screencasts for them.
|
||||
it('mounts a restored client-hosted page on the client pane while the host recovers it', () => {
|
||||
restoreClientHostedHandle()
|
||||
|
||||
@@ -11,12 +11,10 @@ import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { GitHistoryPanel } from '@/components/right-sidebar/source-control/sync/git-history-panel'
|
||||
import { GitGraphTab } from './git-graph-tab'
|
||||
import { GitGraphFilter } from './git-graph-filter'
|
||||
import { useGitGraphHistory, gitGraphFilterSupported } from './use-git-graph-history'
|
||||
import { useGitGraphHistory } from './use-git-graph-history'
|
||||
import type { GitHistoryResult } from '../../../../../shared/git/history/git-history-types'
|
||||
import * as graph from '../../../../../shared/git/history/git-history-graph'
|
||||
|
||||
const { getHistory, getRefs } = vi.hoisted(() => ({ getHistory: vi.fn(), getRefs: vi.fn() }))
|
||||
vi.mock('@/runtime/source-control/git/runtime-git-status-client', async (original) => ({
|
||||
@@ -99,74 +97,6 @@ const result: GitHistoryResult = {
|
||||
limit: 50
|
||||
}
|
||||
|
||||
it('keeps view models on expand and resize, rebuilding when history changes', async () => {
|
||||
const build = vi.spyOn(graph, 'buildGitHistoryViewModels')
|
||||
const render = (history: GitHistoryResult) => (
|
||||
<TooltipProvider>
|
||||
<GitHistoryPanel
|
||||
state={{ status: 'ready', result: history }}
|
||||
collapsed={false}
|
||||
onToggle={() => {}}
|
||||
onRefresh={() => {}}
|
||||
onLoadCommitFiles={async () => []}
|
||||
onOpenCommitFile={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
await act(async () => root.render(render(result)))
|
||||
expect(build).toHaveBeenCalledTimes(1)
|
||||
await act(async () =>
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="git-history-row"]')!.click()
|
||||
)
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector('[role="separator"]')!
|
||||
.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
|
||||
)
|
||||
expect(build).toHaveBeenCalledTimes(1)
|
||||
await act(async () =>
|
||||
root.render(
|
||||
render({
|
||||
...result,
|
||||
items: [...result.items, { id: 'b', subject: 'next', message: 'next', parentIds: [] }]
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(build).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
container.querySelector('[data-testid="git-history-row"]')?.getAttribute('aria-expanded')
|
||||
).toBe('true')
|
||||
})
|
||||
|
||||
it('disables the branch filter when an older host omits the refs echo', async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<GitGraphFilter
|
||||
worktreeId={workspaceId}
|
||||
filter="all"
|
||||
supported={gitGraphFilterSupported(result)}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
)
|
||||
expect(container.querySelector('button')?.disabled).toBe(true)
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<GitGraphFilter
|
||||
worktreeId={workspaceId}
|
||||
filter="all"
|
||||
supported={gitGraphFilterSupported({ ...result, refs: 'all' })}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
)
|
||||
expect(container.querySelector('button')?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('recovers silently after each later token expiry and ignores refreshes for other worktrees', async () => {
|
||||
let history: ReturnType<typeof useGitGraphHistory>
|
||||
function History() {
|
||||
@@ -335,33 +265,6 @@ async function loadOlderHistory() {
|
||||
return { controller, items, page }
|
||||
}
|
||||
|
||||
it('keeps a selection pushed past the loaded depth with exactly one extra page', async () => {
|
||||
const { controller, items } = await loadOlderHistory()
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll('button'))
|
||||
.find((button) => button.textContent === 'Commit 149')!
|
||||
.click()
|
||||
})
|
||||
const refreshed = [
|
||||
{ id: 'new-tip', subject: 'New commit', message: 'New commit', parentIds: [] },
|
||||
...items
|
||||
]
|
||||
getHistory.mockImplementation(async (_owner, options) => ({
|
||||
...result,
|
||||
refs: 'all',
|
||||
historyToken: 'refreshed',
|
||||
items: refreshed.slice(options.skip, options.skip + 50),
|
||||
hasMore: true
|
||||
}))
|
||||
await act(async () => {
|
||||
await controller.refresh()
|
||||
})
|
||||
expect(getHistory).toHaveBeenCalledTimes(4)
|
||||
expect(container.querySelectorAll('[data-current]')).toHaveLength(200)
|
||||
expect(container.querySelector('[data-current="true"] button[title="Commit 149"]')).not.toBeNull()
|
||||
expect(container.querySelector('aside h2')?.textContent).toBe('Commit 149')
|
||||
})
|
||||
|
||||
it('bounds refresh to one extra page when the selected commit has vanished', async () => {
|
||||
const { controller, items } = await loadOlderHistory()
|
||||
const remaining = items.filter((item) => item.id !== 'commit-110')
|
||||
|
||||
@@ -121,45 +121,6 @@ describe('EmulatorDeviceFrame window-visibility gating', () => {
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not tear down on a quick hide/show within the park delay', async () => {
|
||||
vi.useFakeTimers()
|
||||
await renderFrame(true)
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
setDocumentVisibility('hidden')
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS - 100)
|
||||
})
|
||||
await act(async () => {
|
||||
setDocumentVisibility('visible')
|
||||
})
|
||||
// Returning before the grace expires must cancel parking without reconnecting.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS)
|
||||
})
|
||||
expect(stopFrameStream).not.toHaveBeenCalled()
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps streaming while hidden when occlusion state is proven stale (display-sleep wedge)', async () => {
|
||||
vi.useFakeTimers()
|
||||
await renderFrame(true)
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Real input disproves a wedged hidden/occluded signal; streaming must continue instead of leaving a black frame.
|
||||
await act(async () => {
|
||||
setDocumentVisibility('hidden')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS * 2)
|
||||
})
|
||||
expect(stopFrameStream).not.toHaveBeenCalled()
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resumes a parked stream when user input proves hidden visibility is stale', async () => {
|
||||
vi.useFakeTimers()
|
||||
await renderFrame(true)
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { groupEmulatorDevices } from './emulator-device-picker-groups'
|
||||
import { toSimulatorDeviceRows } from './emulator-device-row-mapping'
|
||||
|
||||
describe('emulator picker inventory', () => {
|
||||
it('keeps unavailable records out of used devices and retains every duplicate identity', () => {
|
||||
const rows = toSimulatorDeviceRows([
|
||||
{
|
||||
id: 'old',
|
||||
name: 'iPhone 18 Pro',
|
||||
backend: 'ios',
|
||||
state: 'shutdown',
|
||||
detail: 'com.apple.CoreSimulator.SimRuntime.iOS-27-0',
|
||||
isAvailable: false,
|
||||
lastUsedAt: '2026-10-30',
|
||||
availabilityError: 'runtime removed'
|
||||
},
|
||||
{
|
||||
id: 'used',
|
||||
name: 'iPhone 18 Pro',
|
||||
backend: 'ios',
|
||||
state: 'shutdown',
|
||||
detail: 'com.apple.CoreSimulator.SimRuntime.iOS-27-2',
|
||||
isAvailable: true,
|
||||
lastUsedAt: '2026-10-31'
|
||||
},
|
||||
{
|
||||
id: 'unused',
|
||||
name: 'iPhone 18 Pro',
|
||||
backend: 'ios',
|
||||
state: 'shutdown',
|
||||
detail: 'com.apple.CoreSimulator.SimRuntime.iOS-27-2',
|
||||
isAvailable: true
|
||||
},
|
||||
{
|
||||
id: 'pixel',
|
||||
name: 'Pixel',
|
||||
backend: 'android',
|
||||
state: 'booted',
|
||||
osVersion: '16',
|
||||
apiLevel: '36',
|
||||
isAvailable: true
|
||||
}
|
||||
])
|
||||
const groups = groupEmulatorDevices(rows, 'old')
|
||||
expect(groups.used.map((d) => d.udid)).toEqual(['used', 'pixel'])
|
||||
expect(groups.other.map((d) => d.udid)).toEqual(['unused'])
|
||||
expect(groups.unavailable.map((d) => d.udid)).toEqual(['old'])
|
||||
expect(groups.unavailable[0].availabilityError).toBe('runtime removed')
|
||||
})
|
||||
|
||||
it('includes the selected shutdown device and tolerates metadata from older hosts', () => {
|
||||
const rows = toSimulatorDeviceRows([
|
||||
{ id: 'legacy', name: 'Phone', state: 'shutdown' },
|
||||
{ id: 'selected', name: 'Tablet', state: 'shutdown' }
|
||||
])
|
||||
const groups = groupEmulatorDevices(rows, 'selected')
|
||||
expect(groups.used.map((d) => d.udid)).toEqual(['selected'])
|
||||
expect(groups.other.map((d) => d.udid)).toEqual(['legacy'])
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { EmulatorDevicePicker } from './emulator-device-picker'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('EmulatorDevicePicker', () => {
|
||||
it('reveals other devices during search, keeps unavailable rows disabled, and selects the exact duplicate', async () => {
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<EmulatorDevicePicker
|
||||
selectedUdid="active"
|
||||
onSelectDevice={onSelect}
|
||||
devices={[
|
||||
{ name: 'Pixel', udid: 'active', state: 'Booted', backend: 'android' },
|
||||
{
|
||||
name: 'iPhone',
|
||||
udid: 'old',
|
||||
state: 'Shutdown',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
|
||||
isAvailable: false,
|
||||
availabilityError: 'Missing runtime'
|
||||
},
|
||||
{
|
||||
name: 'iPhone',
|
||||
udid: 'new',
|
||||
state: 'Shutdown',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-27-2'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('combobox'))
|
||||
expect(screen.queryByText('iPhone')).toBeNull()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search devices…'), {
|
||||
target: { value: 'iPhone' }
|
||||
})
|
||||
await waitFor(() => expect(screen.getAllByText('iPhone')).toHaveLength(2))
|
||||
const unavailable = screen.getByText('Missing runtime').closest('[cmdk-item]')
|
||||
expect(unavailable?.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(unavailable!)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('iOS 27.2 · Stopped'))
|
||||
expect(onSelect).toHaveBeenCalledWith('new')
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Search devices…')).toBeNull())
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as emulatorPreferences from '@/lib/emulator-opening-preferences-capability'
|
||||
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
|
||||
import { EMULATOR_OPENING_PREFERENCES_CAPABILITY } from '../../../../shared/emulator-runtime-capabilities'
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
@@ -165,31 +164,6 @@ describe('useEmulatorPaneSession', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('asks the capable host on automatic open without sending a guessed device', async () => {
|
||||
consumePrelaunchedSimulatorSession(WORKTREE_ID)
|
||||
setLocalRuntimeCapabilitiesForTests([EMULATOR_OPENING_PREFERENCES_CAPABILITY])
|
||||
vi.mocked(window.api.runtime.call).mockImplementation(async (request) => {
|
||||
if (request.method === 'emulator.listDevices') {
|
||||
return runtimeSuccess(deviceList)
|
||||
}
|
||||
return runtimeSuccess({ attached: false, selectionRequired: 'first-use' })
|
||||
})
|
||||
await act(async () => {
|
||||
root.render(<AutoAttachProbe />)
|
||||
})
|
||||
await flushEffects()
|
||||
expect(window.api.runtime.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'emulator.attach',
|
||||
params: { worktree: WORKTREE_ID, focus: false, userInitiated: true }
|
||||
})
|
||||
)
|
||||
expect(latest?.loading).toBe(false)
|
||||
expect(latest?.selectedUdid).toBeNull()
|
||||
expect(latest?.selectionRequired).toBe('first-use')
|
||||
expect(latest?.error).toBeNull()
|
||||
})
|
||||
|
||||
it('retains an explicit session when an older automatic capability lookup finishes', async () => {
|
||||
consumePrelaunchedSimulatorSession(WORKTREE_ID)
|
||||
const capability = createDeferred<boolean>()
|
||||
@@ -242,45 +216,6 @@ describe('useEmulatorPaneSession', () => {
|
||||
expect(latest?.displayName).toBe('iPhone B')
|
||||
})
|
||||
|
||||
it('shows a connecting state instead of the old live preview while switching devices', async () => {
|
||||
await act(async () => {
|
||||
root.render(<Probe />)
|
||||
})
|
||||
await flushEffects()
|
||||
|
||||
expect(latest?.isLive).toBe(true)
|
||||
expect(latest?.previewUrl).toBe('http://127.0.0.1:3100/stream.mjpeg')
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(latest?.loading).toBe(true)
|
||||
expect(latest?.isLive).toBe(false)
|
||||
expect(latest?.previewUrl).toBeUndefined()
|
||||
expect(latest?.displayName).toBe('iPhone B')
|
||||
expect(latest?.selectedUdid).toBe('device-b')
|
||||
|
||||
await act(async () => {
|
||||
attachDeferred.resolve({
|
||||
attached: true,
|
||||
info: {
|
||||
deviceUdid: 'device-b',
|
||||
displayName: 'iPhone B',
|
||||
streamUrl: 'http://127.0.0.1:3200/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3200/ws'
|
||||
}
|
||||
})
|
||||
await attachDeferred.promise
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(latest?.loading).toBe(false)
|
||||
expect(latest?.isLive).toBe(true)
|
||||
expect(latest?.previewUrl).toBe('http://127.0.0.1:3200/stream.mjpeg')
|
||||
})
|
||||
|
||||
it('ignores a rotate response from the previous session after switching devices', async () => {
|
||||
await act(async () => {
|
||||
root.render(<Probe />)
|
||||
|
||||
@@ -64,51 +64,3 @@ it('refuses image intake on an older runtime before sending any bytes', async ()
|
||||
).rejects.toThrow('cannot upload images yet')
|
||||
expect(rpc.mock.calls.map((call) => call[1])).toEqual(['status.get'])
|
||||
})
|
||||
|
||||
it.each([2, 20])('checks capability once and uploads each of %i accepted images', async (count) => {
|
||||
rpc.mockImplementation(async (_runtime, method) => {
|
||||
if (method === 'status.get') {
|
||||
return { capabilities: ['github.image-attachments.v1'] }
|
||||
}
|
||||
if (method === 'clipboard.startImageUpload') {
|
||||
return { uploadId: 'image' }
|
||||
}
|
||||
if (method === 'github.uploadAttachment') {
|
||||
return { url: 'https://github.com/user-attachments/assets/image' }
|
||||
}
|
||||
return {}
|
||||
})
|
||||
const upload = captureGitHubImageUpload({
|
||||
runtime: { kind: 'local' },
|
||||
target: { kind: 'repo', repo: 'repo', source: 'origin' }
|
||||
})
|
||||
for (let index = 0; index < count; index++) {
|
||||
await upload(new File(['a'], `${index}.png`, { type: 'image/png' }))
|
||||
}
|
||||
expect(rpc.mock.calls.filter((call) => call[1] === 'status.get')).toHaveLength(1)
|
||||
expect(rpc.mock.calls.filter((call) => call[1] === 'github.uploadAttachment')).toHaveLength(count)
|
||||
})
|
||||
|
||||
it('stages a 5 MiB image in fourteen bounded chunks without putting bytes in Markdown', async () => {
|
||||
rpc.mockImplementation(async (_runtime, method) => {
|
||||
if (method === 'status.get') {
|
||||
return { capabilities: ['github.image-attachments.v1'] }
|
||||
}
|
||||
if (method === 'clipboard.startImageUpload') {
|
||||
return { uploadId: 'large' }
|
||||
}
|
||||
if (method === 'github.uploadAttachment') {
|
||||
return { url: 'https://github.com/user-attachments/assets/large' }
|
||||
}
|
||||
return {}
|
||||
})
|
||||
const url = await captureGitHubImageUpload({
|
||||
runtime: { kind: 'local' },
|
||||
target: { kind: 'repo', repo: 'repo', source: 'issue' }
|
||||
})(new File([new Uint8Array(5 * 1024 * 1024)], 'large.png', { type: 'image/png' }))
|
||||
expect(url).toBe('https://github.com/user-attachments/assets/large')
|
||||
expect(
|
||||
rpc.mock.calls.filter((call) => call[1] === 'clipboard.appendImageUploadChunk')
|
||||
).toHaveLength(14)
|
||||
expect(rpc.mock.calls.filter((call) => call[1] === 'status.get')).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -132,55 +132,6 @@ it('delivers a File drop through the registered owner and detaches an externally
|
||||
expect(view.container.querySelector('output')?.textContent).toBe('Replacement')
|
||||
})
|
||||
|
||||
it.each([2, 20])(
|
||||
'keeps typing independent of %i historical labels and retires them with the draft',
|
||||
async (count) => {
|
||||
upload.mockReset().mockResolvedValue('https://github.com/user-attachments/assets/completed')
|
||||
const changed = vi.fn()
|
||||
const view = render(<Draft initialBody={'text '.repeat(count * 100)} onChange={changed} />)
|
||||
const field = textareaField(view.getByRole('textbox'))
|
||||
field.setSelectionRange(field.value.length, field.value.length)
|
||||
fireEvent.paste(field, {
|
||||
clipboardData: {
|
||||
files: Array.from(
|
||||
{ length: count },
|
||||
(_, index) => new File(['png'], `history-${index}.png`, { type: 'image/png' })
|
||||
)
|
||||
}
|
||||
})
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledTimes(count))
|
||||
await waitFor(() => expect(field.value).not.toContain('Uploading'))
|
||||
let projectionPasses = 0
|
||||
const originalReplace = String.prototype.replace
|
||||
const replace = vi.spyOn(String.prototype, 'replace').mockImplementation(function (
|
||||
this: string,
|
||||
pattern: Parameters<typeof originalReplace>[0],
|
||||
replacement: Parameters<typeof originalReplace>[1]
|
||||
) {
|
||||
if (pattern instanceof RegExp && pattern.source.startsWith('!\\[Uploading ')) {
|
||||
projectionPasses++
|
||||
}
|
||||
return originalReplace.call(this, pattern, replacement)
|
||||
})
|
||||
changed.mockClear()
|
||||
try {
|
||||
fireEvent.change(field, { target: { value: `${field.value} typed` } })
|
||||
expect(projectionPasses).toBe(1)
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(field.value.endsWith(' typed')).toBe(true)
|
||||
} finally {
|
||||
replace.mockRestore()
|
||||
}
|
||||
fireEvent.change(field, { target: { value: `${field.value}![Uploading history-0.png…]()` } })
|
||||
expect(field.value).not.toContain('Uploading')
|
||||
fireEvent.click(view.getByRole('button', { name: 'Replace' }))
|
||||
fireEvent.change(field, { target: { value: 'Replacement ![Uploading history-0.png…]()' } })
|
||||
expect(view.container.querySelector('output')?.textContent).toBe(
|
||||
'Replacement ![Uploading history-0.png…]()'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('removes an undo-restored owned label after an incomplete user label prefix', async () => {
|
||||
const held = Promise.withResolvers<string>()
|
||||
upload.mockReset().mockReturnValue(held.promise)
|
||||
|
||||
@@ -44,21 +44,6 @@ it('removes the previous image immediately when its mounted owner or reference c
|
||||
expect(release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets a new reference load after the previous reference failed', async () => {
|
||||
acquire
|
||||
.mockRejectedValueOnce(new Error('unavailable'))
|
||||
.mockResolvedValueOnce({ url: 'blob:second', release })
|
||||
const view = render(
|
||||
<JiraAttachmentImage target={target} reference="first" alt="shot" compact={false} />
|
||||
)
|
||||
await waitFor(() => expect(acquire).toHaveBeenCalledOnce())
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
view.rerender(
|
||||
<JiraAttachmentImage target={target} reference="second" alt="shot" compact={false} />
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:second'))
|
||||
})
|
||||
|
||||
it('reacquires the same reference after disconnect and reconnect invalidations', async () => {
|
||||
acquire
|
||||
.mockResolvedValueOnce({ url: 'blob:first', release })
|
||||
|
||||
@@ -93,31 +93,6 @@ beforeEach(() => {
|
||||
boundary.search.mockResolvedValue(users)
|
||||
})
|
||||
describe('Existing Jira issue assignee', () => {
|
||||
it('offers self first, alphabetic candidates, issue-scoped search, and unassignment', async () => {
|
||||
const { mutateIssue } = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assignee' }))
|
||||
const search = await screen.findByPlaceholderText('Search users')
|
||||
await screen.findByRole('button', { name: 'Ada' })
|
||||
const rows = screen.getAllByRole('button').map((button) => button.textContent)
|
||||
expect(rows.indexOf('Zoe me')).toBeLessThan(rows.indexOf('Ada'))
|
||||
expect(rows.indexOf('Ada')).toBeLessThan(rows.indexOf('Zoe'))
|
||||
boundary.search.mockResolvedValue([{ accountId: 'beth', displayName: 'Beth' }])
|
||||
fireEvent.change(search, { target: { value: 'Beth' } })
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Beth' }))
|
||||
expect(boundary.search).toHaveBeenLastCalledWith({ kind: 'local' }, 'ENG-1', 'Beth', 'site-1')
|
||||
expect(mutateIssue).toHaveBeenCalledWith(
|
||||
'assignee',
|
||||
{ assigneeAccountId: 'beth' },
|
||||
{ assignee: { accountId: 'beth', displayName: 'Beth' } }
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assignee' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unassigned' }))
|
||||
expect(mutateIssue).toHaveBeenLastCalledWith(
|
||||
'assignee',
|
||||
{ assigneeAccountId: null },
|
||||
{ assignee: undefined }
|
||||
)
|
||||
})
|
||||
it('loads the viewer from the selected source and hides it during an account switch', async () => {
|
||||
let resolve = (_status: JiraConnectionStatus) => {}
|
||||
boundary.readJiraStatus.mockReturnValue(
|
||||
@@ -210,12 +185,4 @@ describe('Existing Jira issue assignee', () => {
|
||||
'site-1'
|
||||
)
|
||||
})
|
||||
it('ignores assignment from a previously opened picker while another field saves', async () => {
|
||||
const { props, rerender, mutateIssue } = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assignee' }))
|
||||
await screen.findByRole('button', { name: 'Ada' })
|
||||
rerender(<JiraIssueMetadataBar {...props} pendingField="title" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ada' }))
|
||||
expect(mutateIssue).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,26 +23,6 @@ describe('usePairingOfferExpiry', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('fires one minute before expiry', () => {
|
||||
const onDue = vi.fn()
|
||||
renderHook(() => usePairingOfferExpiry(EXPIRES_AT, onDue))
|
||||
act(() => vi.advanceTimersByTime(EXPIRES_AT - 60_000 - T0 - 1))
|
||||
expect(onDue).not.toHaveBeenCalled()
|
||||
act(() => vi.advanceTimersByTime(1))
|
||||
expect(onDue).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('waits while hidden and fires on return once due', () => {
|
||||
const onDue = vi.fn()
|
||||
renderHook(() => usePairingOfferExpiry(EXPIRES_AT, onDue))
|
||||
act(() => setVisibility('hidden'))
|
||||
act(() => vi.advanceTimersByTime(EXPIRES_AT - T0))
|
||||
expect(onDue).not.toHaveBeenCalled()
|
||||
act(() => setVisibility('visible'))
|
||||
act(() => vi.advanceTimersByTime(0))
|
||||
expect(onDue).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fires soon after the computer wakes from sleep past the refresh time', () => {
|
||||
const onDue = vi.fn()
|
||||
renderHook(() => usePairingOfferExpiry(EXPIRES_AT, onDue))
|
||||
@@ -51,22 +31,4 @@ describe('usePairingOfferExpiry', () => {
|
||||
act(() => vi.advanceTimersByTime(15_000))
|
||||
expect(onDue).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rechecks when the window regains focus', () => {
|
||||
const onDue = vi.fn()
|
||||
renderHook(() => usePairingOfferExpiry(EXPIRES_AT, onDue))
|
||||
vi.setSystemTime(EXPIRES_AT)
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
act(() => vi.advanceTimersByTime(0))
|
||||
expect(onDue).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stops after unmount and never runs without an expiry', () => {
|
||||
const onDue = vi.fn()
|
||||
const { unmount } = renderHook(() => usePairingOfferExpiry(EXPIRES_AT, onDue))
|
||||
renderHook(() => usePairingOfferExpiry(null, onDue))
|
||||
unmount()
|
||||
act(() => vi.advanceTimersByTime(EXPIRES_AT))
|
||||
expect(onDue).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,31 +92,3 @@ it('holds Continue back from the settings wait until the opted-in launch names i
|
||||
expect(seen.at(-1)).toEqual({ pending: false, resuming: ['a', 'b'] })
|
||||
expect(seen.some((entry) => !entry.pending && entry.resuming.length === 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('holds nothing back once the launch found nothing to resume', async () => {
|
||||
settings(true)
|
||||
rpc.mockResolvedValue({ sessions: [] })
|
||||
await act(async () => root.render(<Probe launch />))
|
||||
expect(seen.at(-1)).toEqual({ pending: false, resuming: [] })
|
||||
})
|
||||
|
||||
it('holds nothing back when the launch asks instead of resuming', () => {
|
||||
settings(false)
|
||||
act(() => root.render(<Probe launch={false} />))
|
||||
expect(seen.at(-1)).toEqual({ pending: false, resuming: [] })
|
||||
})
|
||||
|
||||
it('waits for settings when held chats enable the launch first, then resumes by default', async () => {
|
||||
rpc.mockImplementation((_target, method) =>
|
||||
method === 'agentSession.restartResumable'
|
||||
? Promise.resolve({ sessions: offered })
|
||||
: new Promise(() => {})
|
||||
)
|
||||
await act(async () => root.render(<Probe launch />))
|
||||
expect(rpc).not.toHaveBeenCalledWith(expect.anything(), 'agentSession.restartResumable')
|
||||
|
||||
settings(undefined)
|
||||
await act(async () => root.render(<Probe launch />))
|
||||
|
||||
expect(seen.at(-1)).toEqual({ pending: false, resuming: ['a', 'b'] })
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { StrictMode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionAccountUsage } from '../../../../shared/agent-session/agent-session-account-usage'
|
||||
import NativeChatAccountUsageDialog from './NativeChatAccountUsageDialog'
|
||||
import { openNativeChatAccountUsageModal } from './native-chat-account-usage-modal'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
read: vi.fn(),
|
||||
@@ -87,23 +86,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('/usage modal', () => {
|
||||
it('opens for the chat it was typed in and closes from its Close button', async () => {
|
||||
mocks.read.mockResolvedValue(result)
|
||||
openNativeChatAccountUsageModal({ sessionId: 'chat', target })
|
||||
expect(mocks.openModal).toHaveBeenCalledWith('chat-account-usage', {
|
||||
sessionId: 'chat',
|
||||
target
|
||||
})
|
||||
render(<NativeChatAccountUsageDialog />)
|
||||
expect(screen.getByRole('dialog', { name: 'Codex usage' })).toBeTruthy()
|
||||
await screen.findByText('chat@example.com')
|
||||
expect(mocks.read).toHaveBeenCalledWith(target, 'agentSession.accountUsage', {
|
||||
sessionId: 'chat'
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(mocks.closeModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the newer first read when a replayed mount’s older read fails late', async () => {
|
||||
let failFirst!: (error: Error) => void
|
||||
mocks.read
|
||||
@@ -146,22 +128,6 @@ describe('/usage modal', () => {
|
||||
await screen.findByText('Nothing to reset right now.')
|
||||
})
|
||||
|
||||
it('keeps the reset outcome when the refresh after it fails, through Retry', async () => {
|
||||
mocks.read
|
||||
.mockResolvedValueOnce(result)
|
||||
.mockRejectedValueOnce(new Error('connection lost'))
|
||||
.mockResolvedValueOnce(result)
|
||||
mocks.reset.mockResolvedValueOnce({ outcome: 'reset' })
|
||||
render(<NativeChatAccountUsageDialog />)
|
||||
await screen.findByText('chat@example.com')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset now' }))
|
||||
await screen.findByText('Codex usage couldn’t be loaded. Try again.')
|
||||
expect(screen.getByText('Codex limits were reset.')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByText('chat@example.com')
|
||||
expect(screen.getByText('Codex limits were reset.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reads nothing when a reset confirmed before closing finishes after it', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
mocks.read.mockResolvedValue(result)
|
||||
@@ -179,22 +145,6 @@ describe('/usage modal', () => {
|
||||
expect(mocks.read).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('re-reads the offer when the host’s Codex state lands after the modal opened', async () => {
|
||||
const { reset: _offer, ...withoutOffer } = result
|
||||
mocks.read.mockResolvedValueOnce(withoutOffer).mockResolvedValueOnce(result)
|
||||
render(<NativeChatAccountUsageDialog />)
|
||||
await screen.findByText('chat@example.com')
|
||||
expect(screen.queryByRole('button', { name: 'Reset now' })).toBeNull()
|
||||
act(() => hostStream.snapshot?.(hostState('chat', 0, 'fetching')))
|
||||
expect(mocks.read).toHaveBeenCalledOnce()
|
||||
act(() => hostStream.snapshot?.(hostState('chat', 1)))
|
||||
await screen.findByRole('button', { name: 'Reset now' })
|
||||
expect(mocks.read).toHaveBeenCalledTimes(2)
|
||||
// An unchanged host state reads nothing more.
|
||||
act(() => hostStream.snapshot?.(hostState('chat', 1)))
|
||||
expect(mocks.read).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('collapses host changes during a background read into one follow-up, and sends none after closing', async () => {
|
||||
const finishes: ((value: AgentSessionAccountUsage) => void)[] = []
|
||||
mocks.read.mockResolvedValueOnce(result).mockImplementation(
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { NativeChatComposerPlanChip, useNativeChatPlanMode } from './NativeChatComposerPlanChip'
|
||||
import type { SessionOptionsSurface } from '../../../../shared/native-chat/input/native-chat-session-options'
|
||||
import { structuredAgentSessionInteractionDescriptor } from '../../../../shared/agent-session/options/agent-session-interaction-mode'
|
||||
import { createStructuredAgentSessionOptionState } from '../../../../shared/agent-session/options/structured-agent-session-options'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('@/i18n/use-ui-language-revision', () => ({ useUiLanguageRevision: () => 0 }))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
afterEach(cleanup)
|
||||
|
||||
function PlanChip(props: Parameters<typeof useNativeChatPlanMode>[0]) {
|
||||
return <NativeChatComposerPlanChip control={useNativeChatPlanMode(props)} />
|
||||
}
|
||||
|
||||
it('shows only active Plan, exits with an absolute Build choice, and retains Plan after a failed exit', async () => {
|
||||
const descriptor = structuredAgentSessionInteractionDescriptor({
|
||||
...createStructuredAgentSessionOptionState('codex', null),
|
||||
interactionModeSupported: true
|
||||
})!
|
||||
const setOption = vi
|
||||
.fn<SessionOptionsSurface['setOption']>()
|
||||
.mockRejectedValueOnce(new Error('Disconnected'))
|
||||
.mockResolvedValue({ snapshot: [] })
|
||||
const surface: SessionOptionsSurface = {
|
||||
getSnapshot: () => [descriptor],
|
||||
setOption,
|
||||
invokeAction: async () => ({ snapshot: [] }),
|
||||
subscribe: () => () => {}
|
||||
}
|
||||
const focus = vi.fn()
|
||||
const renderChip = (snapshot: (typeof descriptor)[]) => (
|
||||
<TooltipProvider>
|
||||
<PlanChip surface={surface} snapshot={snapshot} focusComposer={focus} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const view = render(renderChip([descriptor]))
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
const plan = {
|
||||
...descriptor,
|
||||
kind: { type: 'select' as const, currentValue: 'plan', choices: [] }
|
||||
}
|
||||
view.rerender(renderChip([plan]))
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Exit plan mode' })))
|
||||
expect(setOption).toHaveBeenLastCalledWith('interactionMode', 'default')
|
||||
expect(screen.getByRole('button', { name: 'Exit plan mode' })).toHaveProperty('disabled', false)
|
||||
expect(focus).toHaveBeenCalledOnce()
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Exit plan mode' })))
|
||||
expect(setOption).toHaveBeenLastCalledWith('interactionMode', 'default')
|
||||
view.rerender(renderChip([descriptor]))
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
view.rerender(renderChip([]))
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import { Editor, EditorContent } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { focusMountedNativeChatEditor } from './native-chat-mounted-editor-view'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { NativeChatComposerToolbar } from './NativeChatComposerToolbar'
|
||||
import { NativeChatComposerActions } from './NativeChatComposerActions'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
@@ -250,58 +249,3 @@ it('keeps current mode, permissions and Goal reachable through overflow, includi
|
||||
view.unmount()
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('limits a footer resize to its own controls with five and fifty mounted composers', () => {
|
||||
const callbacks = new Map<Element, () => void>()
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(private callback: ResizeObserverCallback) {}
|
||||
observe(target: Element) {
|
||||
callbacks.set(target, () => this.callback([], this))
|
||||
}
|
||||
unobserve(target: Element) {
|
||||
callbacks.delete(target)
|
||||
}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(120)
|
||||
const measured: HTMLElement[] = []
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
|
||||
this: HTMLElement
|
||||
) {
|
||||
if (this.hasAttribute('data-composer-control')) {
|
||||
measured.push(this)
|
||||
}
|
||||
return new DOMRect(0, 0, 40, 24)
|
||||
})
|
||||
for (const population of [5, 50]) {
|
||||
const view = render(
|
||||
<>
|
||||
{Array.from({ length: population }, (_, index) => (
|
||||
<section key={index} aria-label={`Chat ${index}`}>
|
||||
<NativeChatComposerToolbar
|
||||
left={[{ id: 'mode', priority: 1, content: <button>Plan</button> }]}
|
||||
right={[{ id: 'model', content: <button>Model</button> }]}
|
||||
primary={<button>Send</button>}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
const chat = screen.getByRole('region', { name: 'Chat 0' })
|
||||
const toolbar = chat.querySelector('[data-compact]')
|
||||
if (!toolbar) {
|
||||
throw new Error('Expected the mounted composer toolbar')
|
||||
}
|
||||
measured.length = 0
|
||||
act(() => callbacks.get(toolbar)?.())
|
||||
// The changed footer needs at most full and compact widths for its two controls.
|
||||
expect(measured.length).toBeGreaterThan(0)
|
||||
expect(measured.length).toBeLessThanOrEqual(4)
|
||||
expect(measured.every((control) => chat.contains(control))).toBe(true)
|
||||
view.unmount()
|
||||
callbacks.clear()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
makeWorktree,
|
||||
makeFolderWorkspace,
|
||||
TEST_REPO
|
||||
} from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import { makeTab } from '@/store/slices/store-session-test-harness'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { NativeChatComposerWorkspace } from './NativeChatComposerWorkspace'
|
||||
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
|
||||
|
||||
vi.mock('@/store', async () => {
|
||||
const { createTestStore } = await import('@/store/slices/store-test-helpers')
|
||||
return { useAppStore: createTestStore() }
|
||||
})
|
||||
|
||||
const clipboard = vi.fn(async () => {})
|
||||
const wt = makeWorktree({
|
||||
id: 'wt-1',
|
||||
repoId: TEST_REPO.id,
|
||||
branch: 'refs/heads/feature/composer',
|
||||
hostId: 'local'
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('api', undefined)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { writeClipboardText: clipboard } }
|
||||
})
|
||||
clipboard.mockClear()
|
||||
useAppStore.setState({
|
||||
settings: createGlobalSettingsFixture({ machineName: 'My laptop' }),
|
||||
runtimeEnvironments: [],
|
||||
sshTargetLabels: new Map(),
|
||||
repos: [TEST_REPO],
|
||||
worktreesByRepo: { [TEST_REPO.id]: [wt] },
|
||||
detectedWorktreesByRepo: {},
|
||||
tabsByWorktree: { [wt.id]: [makeTab({ id: 'tab-1', worktreeId: wt.id })] },
|
||||
unifiedTabsByWorktree: {},
|
||||
folderWorkspaces: [],
|
||||
structuredSessionLaunchDirectoryByTabId: {},
|
||||
activeWorktreeId: wt.id,
|
||||
activeWorkspaceExecutionHostId: 'local'
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<NativeChatComposerWorkspace terminalTabId="tab-1" />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('chat composer workspace', () => {
|
||||
it('shows the machine, sidebar checkout name and branch without a separate project label', async () => {
|
||||
useAppStore.setState({
|
||||
settings: createGlobalSettingsFixture(),
|
||||
worktreesByRepo: {
|
||||
[TEST_REPO.id]: [{ ...wt, displayName: 'my-checkout', isMainWorktree: true }]
|
||||
}
|
||||
})
|
||||
const getStatus = vi.fn().mockResolvedValue({ machineName: 'Detected laptop' })
|
||||
Object.defineProperty(window.api, 'runtime', {
|
||||
configurable: true,
|
||||
value: { getStatus }
|
||||
})
|
||||
render(<Footer />)
|
||||
const machine = await screen.findByText('Detected laptop')
|
||||
const checkout = screen.getByRole('button', { name: 'Copy my-checkout' })
|
||||
const branch = screen.getByRole('button', { name: 'Copy feature/composer' })
|
||||
expect(
|
||||
machine.compareDocumentPosition(checkout) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy()
|
||||
expect(checkout.compareDocumentPosition(branch) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
fireEvent.click(checkout)
|
||||
expect(clipboard).toHaveBeenCalledWith('my-checkout')
|
||||
act(() => checkout.focus())
|
||||
expect((await screen.findByRole('tooltip')).textContent).toBe(wt.path)
|
||||
expect(screen.queryByText(TEST_REPO.displayName)).toBeNull()
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
settings: createGlobalSettingsFixture({ machineName: 'Renamed laptop' })
|
||||
})
|
||||
)
|
||||
expect(screen.getByText('Renamed laptop')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows and copies the current branch, then follows the sidebar identity after checkout', async () => {
|
||||
render(<Footer />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy feature/composer' }))
|
||||
expect(clipboard).toHaveBeenCalledWith('feature/composer')
|
||||
await act(async () => {})
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: { [TEST_REPO.id]: [{ ...wt, branch: 'refs/heads/main' }] }
|
||||
})
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Copy main' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Copy feature/composer' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a legacy chat with an opaque workspace ID before its unified bucket arrives', () => {
|
||||
const opaque = makeWorktree({
|
||||
id: 'constructor',
|
||||
repoId: TEST_REPO.id,
|
||||
hostId: 'local',
|
||||
path: '/repo/opaque',
|
||||
displayName: 'opaque-checkout',
|
||||
branch: 'refs/heads/opaque-branch'
|
||||
})
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: { [TEST_REPO.id]: [opaque] },
|
||||
tabsByWorktree: { constructor: [makeTab({ id: 'tab-1', worktreeId: opaque.id })] },
|
||||
unifiedTabsByWorktree: {}
|
||||
})
|
||||
render(<Footer />)
|
||||
expect(screen.getByRole('button', { name: 'Copy opaque-branch' })).toBeTruthy()
|
||||
expect(screen.getByText('opaque-checkout')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('copies the full detached HEAD and never invents a branch for a folder', async () => {
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: { [TEST_REPO.id]: [{ ...wt, branch: '', head: 'a1b2c3d4e5f6' }] }
|
||||
})
|
||||
render(<Footer />)
|
||||
expect(screen.getByRole('button', { name: 'Copy Detached HEAD · a1b2c3d' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy Detached HEAD · a1b2c3d' }))
|
||||
expect(clipboard).toHaveBeenCalledWith('a1b2c3d4e5f6')
|
||||
await act(async () => {})
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
folderWorkspaces: [
|
||||
makeFolderWorkspace({
|
||||
name: 'notes',
|
||||
folderPath: '/work/notes',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
],
|
||||
tabsByWorktree: {
|
||||
'folder:folder-1': [makeTab({ id: 'tab-1', worktreeId: 'folder:folder-1' })]
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Copy Detached HEAD · a1b2c3d' })).toBeNull()
|
||||
expect(screen.getByText('notes')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy notes' }))
|
||||
expect(clipboard).toHaveBeenCalledWith('notes')
|
||||
})
|
||||
|
||||
it('identifies a folder project by its checkout name without Git identity', () => {
|
||||
const folder = makeWorktree({
|
||||
id: 'notes-main',
|
||||
repoId: TEST_REPO.id,
|
||||
path: '/work/notes',
|
||||
displayName: 'plain-notes-folder',
|
||||
isMainWorktree: true,
|
||||
branch: '',
|
||||
head: ''
|
||||
})
|
||||
useAppStore.setState({
|
||||
repos: [{ ...TEST_REPO, kind: 'folder', displayName: 'Notes project', path: '/work/notes' }],
|
||||
worktreesByRepo: { [TEST_REPO.id]: [folder] },
|
||||
tabsByWorktree: { [folder.id]: [makeTab({ id: 'tab-1', worktreeId: folder.id })] }
|
||||
})
|
||||
render(<Footer />)
|
||||
expect(screen.getByText('plain-notes-folder')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Copy feature/composer' })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render for unrelated store writes or another workspace branch update', () => {
|
||||
const commits = vi.fn()
|
||||
render(
|
||||
<Profiler id="footer" onRender={commits}>
|
||||
<Footer />
|
||||
</Profiler>
|
||||
)
|
||||
commits.mockClear()
|
||||
act(() => useAppStore.setState({ activeWorktreeId: 'other' }))
|
||||
expect(commits).not.toHaveBeenCalled()
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: {
|
||||
[TEST_REPO.id]: [
|
||||
wt,
|
||||
makeWorktree({ id: 'other', repoId: TEST_REPO.id, branch: 'other-branch' })
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(commits).not.toHaveBeenCalled()
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: { [TEST_REPO.id]: [{ ...wt, branch: 'refs/heads/changed' }] }
|
||||
})
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Copy changed' })).toBeTruthy()
|
||||
expect(commits).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps same-ID host copies separate and leaves the other host unrendered', () => {
|
||||
const remote = { ...wt, hostId: 'runtime:box' as const, branch: 'refs/heads/remote-branch' }
|
||||
useAppStore.setState({
|
||||
runtimeEnvironments: [
|
||||
{
|
||||
id: 'box',
|
||||
name: 'Build Mac',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
endpoints: [],
|
||||
preferredEndpointId: 'direct'
|
||||
}
|
||||
],
|
||||
repos: [TEST_REPO, { ...TEST_REPO, executionHostId: 'runtime:box' }],
|
||||
worktreesByRepo: { [TEST_REPO.id]: [wt, remote] },
|
||||
activeWorkspaceExecutionHostId: 'runtime:box'
|
||||
})
|
||||
const localCommits = vi.fn()
|
||||
const remoteCommits = vi.fn()
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Profiler id="local" onRender={localCommits}>
|
||||
<NativeChatComposerWorkspace
|
||||
terminalTabId="tab-1"
|
||||
structuredTransport={{ worktreeId: wt.id, runtimeEnvironmentId: null }}
|
||||
/>
|
||||
</Profiler>
|
||||
<Profiler id="remote" onRender={remoteCommits}>
|
||||
<NativeChatComposerWorkspace
|
||||
terminalTabId="remote-tab"
|
||||
structuredTransport={{ worktreeId: wt.id, runtimeEnvironmentId: 'box' }}
|
||||
/>
|
||||
</Profiler>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Copy feature/composer' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Copy remote-branch' })).toBeTruthy()
|
||||
expect(screen.getByText('Build Mac')).toBeTruthy()
|
||||
expect(screen.getByText('My laptop')).toBeTruthy()
|
||||
localCommits.mockClear()
|
||||
remoteCommits.mockClear()
|
||||
act(() =>
|
||||
useAppStore.getState().setEnvironmentSshConnectionState('box', 'target', {
|
||||
targetId: 'target',
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
)
|
||||
expect(localCommits).not.toHaveBeenCalled()
|
||||
expect(remoteCommits).not.toHaveBeenCalled()
|
||||
act(() =>
|
||||
useAppStore.getState().setEnvironmentRemovedSshTargetLabels('other', { target: 'Other box' })
|
||||
)
|
||||
expect(localCommits).not.toHaveBeenCalled()
|
||||
expect(remoteCommits).not.toHaveBeenCalled()
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
runtimeEnvironments: useAppStore.getState().runtimeEnvironments.map((environment) => ({
|
||||
...environment,
|
||||
name: 'Renamed server'
|
||||
}))
|
||||
})
|
||||
)
|
||||
expect(screen.getByText('Renamed server')).toBeTruthy()
|
||||
expect(remoteCommits).toHaveBeenCalledTimes(1)
|
||||
expect(localCommits).not.toHaveBeenCalled()
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: { [TEST_REPO.id]: [wt, { ...remote, branch: 'refs/heads/remote-new' }] }
|
||||
})
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Copy remote-new' })).toBeTruthy()
|
||||
expect(localCommits).not.toHaveBeenCalled()
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: { [TEST_REPO.id]: [{ ...wt, branch: 'local-new' }, remote] }
|
||||
})
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Copy local-new' })).toBeTruthy()
|
||||
expect(localCommits).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { FileLinkExists } from '../../../../shared/native-chat/native-chat-file-link-tree'
|
||||
import { NativeChatPacedMarkdown } from './NativeChatPacedMarkdown'
|
||||
import { NativeChatFileLinkExistenceContext } from './native-chat-file-link-existence'
|
||||
import type { NativeChatFileLinkExistence } from '../../../../shared/native-chat/native-chat-file-link-existence'
|
||||
import {
|
||||
NativeChatReplyRevealsContext,
|
||||
type NativeChatReplyReveals
|
||||
} from './native-chat-reply-reveals'
|
||||
import { NATIVE_CHAT_TEXT_REVEAL_DELAY_MS } from '../../../../shared/native-chat/transcript/native-chat-text-reveal'
|
||||
import { NativeChatLinkClickContext } from './native-chat-link-click-context'
|
||||
|
||||
vi.mock('@/hooks/usePrefersReducedMotion', () => ({ usePrefersReducedMotion: () => false }))
|
||||
vi.mock('./native-chat-visual-markdown-extension', () => ({
|
||||
useNativeChatVisualMarkdownExtension: () => undefined
|
||||
}))
|
||||
|
||||
const reveals: NativeChatReplyReveals = { begun: new Set(['reply']), drawn: new Map() }
|
||||
const check = vi.fn<FileLinkExists>(() => true)
|
||||
const peek = vi.fn<FileLinkExists>(() => false)
|
||||
const snapshot = { check, peek }
|
||||
const existence: NativeChatFileLinkExistence = {
|
||||
watch: () => ({ subscribe: () => () => {}, getSnapshot: () => snapshot }),
|
||||
recheck: () => {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
reveals.drawn.clear()
|
||||
check.mockClear()
|
||||
peek.mockClear()
|
||||
})
|
||||
|
||||
it('asks the host about paths only once a paced reply has been fully revealed', () => {
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
'requestAnimationFrame',
|
||||
'cancelAnimationFrame',
|
||||
'performance'
|
||||
]
|
||||
})
|
||||
const onLinkClick = vi.fn()
|
||||
const reply = (streaming: boolean) => (
|
||||
<NativeChatFileLinkExistenceContext.Provider value={existence}>
|
||||
<NativeChatReplyRevealsContext.Provider value={reveals}>
|
||||
<NativeChatLinkClickContext value={onLinkClick}>
|
||||
<NativeChatPacedMarkdown
|
||||
rowKey="reply"
|
||||
content="Edited src/app.ts for you."
|
||||
streaming={streaming}
|
||||
variant="document"
|
||||
linkifyFilePaths
|
||||
/>
|
||||
</NativeChatLinkClickContext>
|
||||
</NativeChatReplyRevealsContext.Provider>
|
||||
</NativeChatFileLinkExistenceContext.Provider>
|
||||
)
|
||||
const view = render(reply(true))
|
||||
act(() => vi.advanceTimersByTime(100))
|
||||
// Why: the stream has ended but the text is still being drawn, so it counts as arriving.
|
||||
view.rerender(reply(false))
|
||||
expect(check).not.toHaveBeenCalled()
|
||||
|
||||
act(() => vi.advanceTimersByTime(NATIVE_CHAT_TEXT_REVEAL_DELAY_MS))
|
||||
|
||||
expect(check).toHaveBeenCalledWith(expect.objectContaining({ pathText: 'src/app.ts' }))
|
||||
expect(view.container.querySelector('a')?.textContent).toBe('src/app.ts')
|
||||
})
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
|
||||
import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/agent-session/launch/structured-agent-session-launch-registry'
|
||||
import {
|
||||
resetStructuredAgentLaunchPersistenceForTests,
|
||||
writeStructuredAgentLaunchRecord
|
||||
} from '@/lib/agent-session/launch/structured-agent-session-launch-persistence'
|
||||
import { resetStructuredAgentSessionSendsForTests } from './structured-agent-session-message-sender'
|
||||
import { structuredSessionNotices } from './native-chat-structured-session-notices'
|
||||
import { NativeChatComposerNotices } from './NativeChatComposerNotices'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import fr from '@/i18n/locales/fr.json'
|
||||
import type * as Launch from '@/lib/agent-session/launch/structured-agent-session-launch'
|
||||
|
||||
const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () =>
|
||||
(await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks()
|
||||
)
|
||||
vi.mock('@/lib/agent-session/launch/structured-agent-session-launch', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof Launch>()),
|
||||
retryStructuredAgentSessionLaunch: mocks.retryLaunch
|
||||
}))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () =>
|
||||
moduleFactories.structuredAgentSessionClient()
|
||||
)
|
||||
vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession())
|
||||
vi.mock('./use-native-chat-tab-owner', () => moduleFactories.useNativeChatTabOwner())
|
||||
vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize())
|
||||
vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext())
|
||||
vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick())
|
||||
vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList())
|
||||
vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer())
|
||||
vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard())
|
||||
vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard())
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
resetStructuredSessionMocks()
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentSessionSendsForTests()
|
||||
localStorage.clear()
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
function restoreOpening() {
|
||||
writeStructuredAgentLaunchRecord({
|
||||
sessionId: 'opening-notice-session',
|
||||
executionHostId: 'local',
|
||||
agent: 'codex',
|
||||
lifecycle: 'visibility-unknown',
|
||||
clientOperationId: 'original-create',
|
||||
payloadFingerprint: 'original-payload',
|
||||
expectedRuntimeFence: null,
|
||||
createMessageSupport: true,
|
||||
firstMessage: {
|
||||
clientMessageId: 'original-opening',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Keep this opening' }] }
|
||||
}
|
||||
})
|
||||
mocks.messages = [
|
||||
{
|
||||
id: 'orca:original-opening',
|
||||
role: 'user',
|
||||
source: 'transcript',
|
||||
timestamp: 1,
|
||||
blocks: [{ type: 'text', text: 'Keep this opening' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function renderPane() {
|
||||
return render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
isFocusedGroup
|
||||
tabId="opening-notice-tab"
|
||||
sessionId="opening-notice-session"
|
||||
target={{ kind: 'local' }}
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
it.each(['en', 'fr'])(
|
||||
'keeps restored opening recovery with its launch notice in %s',
|
||||
async (language) => {
|
||||
i18n.addResourceBundle('fr', 'translation', fr)
|
||||
await i18n.changeLanguage(language)
|
||||
restoreOpening()
|
||||
mocks.status = 'error'
|
||||
mocks.readRefusal = {
|
||||
code: 'agent_session_identity_required',
|
||||
details: { reason: 'recordMissing' }
|
||||
}
|
||||
renderPane()
|
||||
expect(mocks.controllerProps?.transportEnabled).toBe(true)
|
||||
expect(mocks.composerProps?.notices?.map(({ key }) => key)).toEqual(['launch'])
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(mocks.retryLaunch).toHaveBeenCalledExactlyOnceWith('wt-1', 'opening-notice-session')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an unrelated read refusal with its history owner despite unresolved creation', () => {
|
||||
restoreOpening()
|
||||
mocks.status = 'error'
|
||||
mocks.readRefusal = {
|
||||
code: 'agent_session_journal_unreadable',
|
||||
details: { reason: 'journalUnavailable' }
|
||||
}
|
||||
renderPane()
|
||||
expect(mocks.composerProps?.notices?.map(({ key }) => key)).toEqual(['session'])
|
||||
})
|
||||
|
||||
it('preserves the launch cause and its action instead of borrowing it for missing-history advice', () => {
|
||||
const retry = vi.fn()
|
||||
const notices = structuredSessionNotices({
|
||||
launch: {
|
||||
lifecycle: 'visibility-unknown',
|
||||
failure: {
|
||||
kind: 'refused',
|
||||
code: 'agent_session_operation_invalid',
|
||||
details: { reason: 'cliMissing' },
|
||||
causeText: 'Codex needs its command installed.'
|
||||
},
|
||||
retry
|
||||
},
|
||||
agentLabel: 'Codex',
|
||||
sessionError: 'Missing history advice',
|
||||
readRefusal: {
|
||||
code: 'agent_session_identity_required',
|
||||
details: { reason: 'recordMissing' }
|
||||
},
|
||||
composerError: null
|
||||
})
|
||||
expect(notices.map(({ key }) => key)).toEqual(['launch'])
|
||||
render(<NativeChatComposerNotices notices={notices} />)
|
||||
expect(screen.getByText(/Codex needs its command installed/)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(retry).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -1,118 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NativeChatToolLine } from './NativeChatToolLine'
|
||||
import { NativeChatDiffCard } from './NativeChatDiffCard'
|
||||
import { createNativeChatFileHref } from '../../../../shared/native-chat/native-chat-href-routing'
|
||||
import type { NativeChatEditFile } from '../../../../shared/native-chat/tools/native-chat-edit-model'
|
||||
import { NativeChatLinkClickContext } from './native-chat-link-click-context'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const editFile = (overrides: Partial<NativeChatEditFile>): NativeChatEditFile => ({
|
||||
path: 'src/main.ts',
|
||||
oldPath: null,
|
||||
changeKind: 'edited',
|
||||
lines: [{ kind: 'add', text: 'x', oldLineNumber: null, newLineNumber: 1 }],
|
||||
added: 1,
|
||||
removed: 0,
|
||||
lineNumbersKnown: true,
|
||||
truncated: false,
|
||||
...overrides
|
||||
})
|
||||
|
||||
function withLinks(ui: React.ReactElement, onLinkClick: () => void): React.ReactElement {
|
||||
return <NativeChatLinkClickContext value={onLinkClick}>{ui}</NativeChatLinkClickContext>
|
||||
}
|
||||
|
||||
describe('tool row file targets', () => {
|
||||
it('opens a read file in Orca without toggling the row', () => {
|
||||
const onLinkClick = vi.fn()
|
||||
render(
|
||||
withLinks(
|
||||
<NativeChatToolLine
|
||||
block={{ type: 'tool-call', name: 'Read', input: { file_path: '/repo/src/main.ts' } }}
|
||||
result={{ type: 'tool-result', output: 'contents' }}
|
||||
/>,
|
||||
onLinkClick
|
||||
)
|
||||
)
|
||||
const link = screen.getByRole('link', { name: '/repo/src/main.ts' })
|
||||
expect(link).toHaveTextContent('main.ts')
|
||||
fireEvent.click(link)
|
||||
expect(onLinkClick).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
createNativeChatFileHref('/repo/src/main.ts', 'literal')
|
||||
)
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.getByRole('button')).toHaveAccessibleName(/Read.*\/repo\/src\/main.ts/)
|
||||
})
|
||||
|
||||
it('opens from the keyboard', () => {
|
||||
const onLinkClick = vi.fn()
|
||||
render(
|
||||
withLinks(
|
||||
<NativeChatToolLine
|
||||
block={{ type: 'tool-call', name: 'Write', input: { file_path: 'new.ts', content: 'a' } }}
|
||||
/>,
|
||||
onLinkClick
|
||||
)
|
||||
)
|
||||
fireEvent.keyDown(screen.getByRole('link'), { key: 'Enter' })
|
||||
expect(onLinkClick).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
createNativeChatFileHref('new.ts', 'literal')
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the name plain when the chat cannot open files, and for non-file tools', () => {
|
||||
render(
|
||||
<NativeChatToolLine
|
||||
block={{ type: 'tool-call', name: 'Read', input: { file_path: '/repo/src/main.ts' } }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('link')).toBeNull()
|
||||
cleanup()
|
||||
render(
|
||||
withLinks(
|
||||
<NativeChatToolLine
|
||||
block={{ type: 'tool-call', name: 'Grep', input: { pattern: 'TODO', path: '/repo' } }}
|
||||
/>,
|
||||
vi.fn()
|
||||
)
|
||||
)
|
||||
expect(screen.queryByRole('link')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens an edited file from its diff card without expanding the card', () => {
|
||||
const onLinkClick = vi.fn()
|
||||
render(withLinks(<NativeChatDiffCard file={editFile({})} />, onLinkClick))
|
||||
fireEvent.click(screen.getByRole('link', { name: 'src/main.ts' }))
|
||||
expect(onLinkClick).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
createNativeChatFileHref('src/main.ts', 'literal')
|
||||
)
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the new name of a renamed file, and nothing for a deleted one', () => {
|
||||
render(
|
||||
withLinks(
|
||||
<NativeChatDiffCard
|
||||
file={editFile({ changeKind: 'renamed', oldPath: 'old.ts', path: 'new.ts' })}
|
||||
/>,
|
||||
vi.fn()
|
||||
)
|
||||
)
|
||||
expect(screen.getAllByRole('link').map((link) => link.textContent)).toEqual(['new.ts'])
|
||||
cleanup()
|
||||
render(
|
||||
withLinks(
|
||||
<NativeChatDiffCard file={editFile({ changeKind: 'deleted', lines: [] })} />,
|
||||
vi.fn()
|
||||
)
|
||||
)
|
||||
expect(screen.queryByRole('link')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
NATIVE_CHAT_VISUAL_PING_TYPE,
|
||||
NATIVE_CHAT_VISUAL_PONG_TIMEOUT_MS,
|
||||
NATIVE_CHAT_VISUAL_PONG_TYPE,
|
||||
NATIVE_CHAT_VISUAL_READER_SCROLL_TYPE,
|
||||
NATIVE_CHAT_VISUAL_SIZE_TYPE,
|
||||
NATIVE_CHAT_VISUAL_THEME_TYPE
|
||||
} from '../../../../shared/native-chat/native-chat-visual-shell'
|
||||
|
||||
@@ -23,7 +21,6 @@ vi.mock('@/lib/browser/navigation/http-link-routing', () => ({
|
||||
|
||||
import { NativeChatVisualFrame } from './NativeChatVisualFrame'
|
||||
import type { ReaderGesture } from './native-chat-autoscroll'
|
||||
import { acquireWebviewsDragPassthrough } from '../browser-pane/host-guest/webview-drag-passthrough'
|
||||
|
||||
const visual = { revision: 'r1', html: '<p>chart</p>', path: '/v' }
|
||||
|
||||
@@ -123,21 +120,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('NativeChatVisualFrame', () => {
|
||||
it('lets a dragged tab pass over the frame while a tab drag holds pointer passthrough', () => {
|
||||
const { container } = render(<Harness />)
|
||||
const frame = frameOf(container)
|
||||
expect(frame.style.pointerEvents).toBe('')
|
||||
|
||||
let release = (): void => {}
|
||||
act(() => {
|
||||
release = acquireWebviewsDragPassthrough()
|
||||
})
|
||||
expect(frame.style.pointerEvents).toBe('none')
|
||||
|
||||
act(() => release())
|
||||
expect(frame.style.pointerEvents).toBe('')
|
||||
})
|
||||
|
||||
it('runs the visual in a scripts-only sandbox, named for host registration, with its CSP first', () => {
|
||||
const { container } = render(<Harness />)
|
||||
const frame = frameOf(container)
|
||||
@@ -148,62 +130,6 @@ describe('NativeChatVisualFrame', () => {
|
||||
expect(channelOf(frame)).toMatch(/^[0-9a-f]{32}$/)
|
||||
})
|
||||
|
||||
it('fits its height to what the frame reports, ignoring other windows and channels', () => {
|
||||
const { container } = render(<Harness />)
|
||||
const frame = frameOf(container)
|
||||
const box = frame.parentElement
|
||||
const channel = channelOf(frame)
|
||||
post({ type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel, height: 420 }, frame.contentWindow)
|
||||
expect(box).toHaveStyle({ height: '420px' })
|
||||
post({ type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel, height: 900 }, window)
|
||||
post({ type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'other', height: 900 }, frame.contentWindow)
|
||||
expect(box).toHaveStyle({ height: '420px' })
|
||||
})
|
||||
|
||||
it('hands a wheel or touch its page could not take to the transcript as a reader gesture', () => {
|
||||
const onReaderGesture = vi.fn()
|
||||
const { container } = render(<Harness onReaderGesture={onReaderGesture} />)
|
||||
const frame = frameOf(container)
|
||||
const channel = channelOf(frame)
|
||||
const scroll = { type: NATIVE_CHAT_VISUAL_READER_SCROLL_TYPE, channel }
|
||||
post({ ...scroll, input: 'wheel', deltaY: -30 }, frame.contentWindow)
|
||||
expect(onReaderGesture).toHaveBeenLastCalledWith({ kind: 'wheel', deltaY: -30, zoom: false })
|
||||
post({ ...scroll, input: 'touch' }, frame.contentWindow)
|
||||
expect(onReaderGesture).toHaveBeenLastCalledWith({ kind: 'touch-drag' })
|
||||
post({ ...scroll, input: 'wheel', deltaY: -30 }, window)
|
||||
post({ ...scroll, channel: 'other', input: 'wheel', deltaY: -30 }, frame.contentWindow)
|
||||
expect(onReaderGesture).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('restyles in place on a theme change instead of reloading the visual', () => {
|
||||
const { container } = render(<Harness />)
|
||||
const frame = frameOf(container)
|
||||
const contentWindow = frame.contentWindow
|
||||
if (!contentWindow) {
|
||||
throw new Error('no content window')
|
||||
}
|
||||
const postMessage = vi.spyOn(contentWindow, 'postMessage')
|
||||
act(() => {
|
||||
frame.dispatchEvent(new Event('load'))
|
||||
})
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: NATIVE_CHAT_VISUAL_THEME_TYPE, colorScheme: 'light' }),
|
||||
'*'
|
||||
)
|
||||
const srcdoc = frame.getAttribute('srcdoc')
|
||||
act(() => {
|
||||
document.documentElement.classList.add('dark')
|
||||
})
|
||||
return vi.waitFor(() => {
|
||||
expect(postMessage).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ type: NATIVE_CHAT_VISUAL_THEME_TYPE, colorScheme: 'dark' }),
|
||||
'*'
|
||||
)
|
||||
expect(frameOf(container)).toBe(frame)
|
||||
expect(frame.getAttribute('srcdoc')).toBe(srcdoc)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a link only while the frame has focus and the user has just acted', () => {
|
||||
const { container } = render(<Harness />)
|
||||
const frame = frameOf(container)
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
/** A phone’s input lease must visibly lock the composer and offer Take back regardless of draft contents. */
|
||||
|
||||
import { createRef } from 'react'
|
||||
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
restoreTerminalFitToDesktop: vi.fn<(ptyId: string, settings: unknown) => Promise<boolean>>(),
|
||||
refitAndRefreshAllTerminalPanes: vi.fn(),
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' }
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => ({ settings: mocks.settings }) }
|
||||
}))
|
||||
vi.mock('../terminal-pane/viewport/terminal-fit-restore', () => ({
|
||||
restoreTerminalFitToDesktop: mocks.restoreTerminalFitToDesktop
|
||||
}))
|
||||
vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({
|
||||
refitAndRefreshAllTerminalPanes: mocks.refitAndRefreshAllTerminalPanes
|
||||
}))
|
||||
vi.mock('./NativeChatComposerActions', () => ({
|
||||
NativeChatComposerActions: () => <div data-testid="composer-actions" />
|
||||
}))
|
||||
vi.mock('./NativeChatAutocompleteMenus', () => ({
|
||||
NativeChatMentionMenu: () => null,
|
||||
NativeChatPickerMenu: () => null
|
||||
}))
|
||||
|
||||
import { NativeChatComposerField } from './NativeChatComposerField'
|
||||
import type { NativeChatComposerNotice } from './native-chat-composer-notice'
|
||||
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
|
||||
import {
|
||||
nativeChatMobileHoldNotice,
|
||||
useNativeChatMobileHoldNotice
|
||||
} from './use-native-chat-mobile-hold-notice'
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.restoreTerminalFitToDesktop.mockReset()
|
||||
mocks.refitAndRefreshAllTerminalPanes.mockReset()
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
|
||||
callback()
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function HeldField({
|
||||
notices,
|
||||
goalActive
|
||||
}: {
|
||||
notices: readonly NativeChatComposerNotice[]
|
||||
goalActive: boolean
|
||||
}): React.JSX.Element {
|
||||
const imeEnterGesture = useImeEnterGestureOwnership()
|
||||
return (
|
||||
<NativeChatComposerField
|
||||
draftScopeKey="pane-held"
|
||||
textareaRef={createRef<HTMLTextAreaElement>()}
|
||||
draft="half-written reply"
|
||||
disabled
|
||||
hasPty
|
||||
canSend={false}
|
||||
autocomplete={{ mode: 'none' }}
|
||||
activeSuggestion={0}
|
||||
notices={notices}
|
||||
imageAttachments={[]}
|
||||
sendButtonDisabled
|
||||
isWorking={false}
|
||||
attachDisabled
|
||||
dictationDisabled
|
||||
isDictating={false}
|
||||
isDictationHoldMode={false}
|
||||
imeEnterGesture={imeEnterGesture}
|
||||
goalMode={{ active: goalActive, exit: vi.fn(), interceptPick: (pick) => pick }}
|
||||
onDraftChange={vi.fn()}
|
||||
onTextareaSelect={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
onImeSettled={vi.fn()}
|
||||
onPaste={vi.fn()}
|
||||
pickerListboxId="picker"
|
||||
onChoosePickerItem={vi.fn()}
|
||||
onRetrySkills={vi.fn()}
|
||||
onChooseMentionFile={vi.fn()}
|
||||
mentionFiles={{ files: [], loading: false, failed: false }}
|
||||
onRemoveImageAttachment={vi.fn()}
|
||||
onAttach={vi.fn()}
|
||||
onDictationToggle={vi.fn()}
|
||||
onDictationHoldStart={vi.fn()}
|
||||
onDictationHoldEnd={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
sessionOptionsSurface={null}
|
||||
sessionOptionsSnapshot={[]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('the chat composer while a phone holds the input', () => {
|
||||
it('names the lock and offers Take back with a draft and goal mode on', () => {
|
||||
const onTakeBack = vi.fn()
|
||||
render(<HeldField notices={[nativeChatMobileHoldNotice(false, onTakeBack)]} goalActive />)
|
||||
|
||||
expect(screen.getByText('Input is held by another device.')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Take back' }))
|
||||
expect(onTakeBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNativeChatMobileHoldNotice', () => {
|
||||
it("runs the terminal's reclaim for this pty and refits once it lands", async () => {
|
||||
let resolveRestore: (restored: boolean) => void = () => {}
|
||||
mocks.restoreTerminalFitToDesktop.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveRestore = resolve
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() => useNativeChatMobileHoldNotice('pty-1', false))
|
||||
expect(result.current?.kind).toBe('device')
|
||||
expect(result.current?.action?.disabled).toBe(false)
|
||||
|
||||
act(() => result.current?.action?.onClick())
|
||||
expect(mocks.restoreTerminalFitToDesktop).toHaveBeenCalledWith('pty-1', mocks.settings)
|
||||
// Pending blocks a second reclaim while the first is in flight.
|
||||
expect(result.current?.action?.disabled).toBe(true)
|
||||
|
||||
await act(async () => resolveRestore(true))
|
||||
expect(mocks.refitAndRefreshAllTerminalPanes).toHaveBeenCalledTimes(1)
|
||||
expect(result.current?.action?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves the panes alone when there was nothing to reclaim', async () => {
|
||||
mocks.restoreTerminalFitToDesktop.mockResolvedValue(false)
|
||||
const { result } = renderHook(() => useNativeChatMobileHoldNotice('pty-1', false))
|
||||
|
||||
await act(async () => result.current?.action?.onClick())
|
||||
expect(mocks.refitAndRefreshAllTerminalPanes).not.toHaveBeenCalled()
|
||||
expect(result.current?.action?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,442 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
|
||||
import { makeDetectedResult } from '@/store/slices/worktrees-detected-listing-fixtures'
|
||||
import {
|
||||
hasCachedWindowsTerminalCapabilities,
|
||||
resetWindowsTerminalCapabilitiesForTests
|
||||
} from '@/lib/windows-terminal-capabilities'
|
||||
import { createTestStore } from '@/store/slices/store-test-helpers'
|
||||
import {
|
||||
makeWorktree,
|
||||
makeFolderWorkspace,
|
||||
TEST_REPO
|
||||
} from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import { makeTab, makeUnifiedTab } from '@/store/slices/store-session-test-harness'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { createNativeChatComposerWorkspaceSelector } from './native-chat-composer-workspace'
|
||||
|
||||
vi.mock('@/lib/renderer-app-platform', () => ({ getRendererAppPlatform: () => 'win32' }))
|
||||
|
||||
afterEach(() => {
|
||||
resetWindowsTerminalCapabilitiesForTests()
|
||||
})
|
||||
|
||||
describe('composer workspace context', () => {
|
||||
it('preserves a terminal chat SSH host even when a local twin is selected', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
sshTargetLabels: new Map([['box', 'Build server']]),
|
||||
repos: [TEST_REPO, { ...TEST_REPO, executionHostId: 'ssh:box' }],
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeWorkspaceExecutionHostId: 'local',
|
||||
worktreesByRepo: {
|
||||
[TEST_REPO.id]: [
|
||||
makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id, hostId: 'local', branch: 'local' }),
|
||||
makeWorktree({
|
||||
id: 'wt-1',
|
||||
repoId: TEST_REPO.id,
|
||||
hostId: 'ssh:box',
|
||||
branch: 'ssh',
|
||||
path: '/srv/work'
|
||||
})
|
||||
]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'wt-1': [makeTab({ id: 'chat', worktreeId: 'wt-1' })]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
makeUnifiedTab({
|
||||
id: 'chat',
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group',
|
||||
executionHostId: 'ssh:box'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(createNativeChatComposerWorkspaceSelector('chat')(store.getState())).toMatchObject({
|
||||
branch: 'ssh',
|
||||
hostId: 'ssh:box',
|
||||
machine: 'Build server',
|
||||
kind: 'worktree',
|
||||
path: '/srv/work'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['git', true, 'main'],
|
||||
['git', false, 'worktree'],
|
||||
['folder', true, 'folder']
|
||||
] as const)('identifies a %s checkout before Git identity arrives', (repoKind, main, kind) => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
repos: [{ ...TEST_REPO, kind: repoKind }],
|
||||
worktreesByRepo: {
|
||||
[TEST_REPO.id]: [
|
||||
makeWorktree({
|
||||
id: 'wt',
|
||||
repoId: TEST_REPO.id,
|
||||
hostId: 'local',
|
||||
displayName: 'Sidebar name',
|
||||
isMainWorktree: main,
|
||||
branch: '',
|
||||
head: ''
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(
|
||||
createNativeChatComposerWorkspaceSelector('chat', {
|
||||
worktreeId: 'wt',
|
||||
runtimeEnvironmentId: null
|
||||
})(store.getState())
|
||||
).toMatchObject({ checkout: 'Sidebar name', kind })
|
||||
})
|
||||
|
||||
it('uses this floating session pin and waits through missing or stale pins', () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = FLOATING_TERMINAL_WORKTREE_ID
|
||||
store.setState({
|
||||
floatingWorkspacePath: '/home/me/changed-setting',
|
||||
unifiedTabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
makeUnifiedTab({
|
||||
id: 'chat',
|
||||
worktreeId,
|
||||
groupId: 'group',
|
||||
entityId: 'session-1',
|
||||
contentType: 'agent-session',
|
||||
agentSessionAgent: 'codex'
|
||||
})
|
||||
]
|
||||
},
|
||||
structuredSessionLaunchDirectoryByTabId: {}
|
||||
})
|
||||
const select = createNativeChatComposerWorkspaceSelector('chat', {
|
||||
worktreeId,
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
expect(select(store.getState()).path).toBeUndefined()
|
||||
store.setState({
|
||||
structuredSessionLaunchDirectoryByTabId: {
|
||||
chat: { sessionId: 'old-session', launchDirectory: '/home/me/stale' }
|
||||
}
|
||||
})
|
||||
expect(select(store.getState()).path).toBeUndefined()
|
||||
store.setState({
|
||||
structuredSessionLaunchDirectoryByTabId: {
|
||||
chat: { sessionId: 'session-1', launchDirectory: '/home/me/pinned' }
|
||||
}
|
||||
})
|
||||
expect(select(store.getState())).toMatchObject({ path: '/home/me/pinned', branch: '' })
|
||||
store.setState({ floatingWorkspacePath: '/home/me/another-setting' })
|
||||
expect(select(store.getState()).path).toBe('/home/me/pinned')
|
||||
store.setState({
|
||||
unifiedTabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
makeUnifiedTab({
|
||||
id: 'chat',
|
||||
worktreeId,
|
||||
groupId: 'group',
|
||||
entityId: 'session-2',
|
||||
contentType: 'agent-session',
|
||||
agentSessionAgent: 'codex'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(select(store.getState()).path).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the addressed Windows project runtime without a capability probe or active-project fallback', () => {
|
||||
const store = createTestStore()
|
||||
const local = makeWorktree({
|
||||
id: 'wt',
|
||||
repoId: TEST_REPO.id,
|
||||
projectId: 'local-project',
|
||||
hostId: 'local',
|
||||
path: 'C:\\src\\repo'
|
||||
})
|
||||
const remote = { ...local, projectId: 'remote-project', hostId: 'runtime:hub' as const }
|
||||
const project = {
|
||||
id: 'local-project',
|
||||
displayName: 'Local project',
|
||||
badgeColor: 'blue',
|
||||
sourceRepoIds: [TEST_REPO.id],
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
store.setState({
|
||||
settings: createGlobalSettingsFixture({
|
||||
machineName: 'Windows PC',
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}),
|
||||
repos: [TEST_REPO, { ...TEST_REPO, executionHostId: 'runtime:hub' }],
|
||||
projects: [
|
||||
{
|
||||
...project,
|
||||
id: 'remote-project',
|
||||
sourceRepoIds: [],
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Wrong distro' }
|
||||
},
|
||||
{ ...project, localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }
|
||||
],
|
||||
activeRepoId: TEST_REPO.id,
|
||||
activeWorktreeId: 'wt',
|
||||
activeWorkspaceExecutionHostId: 'runtime:hub',
|
||||
worktreesByRepo: { [TEST_REPO.id]: [remote, local] },
|
||||
tabsByWorktree: { wt: [makeTab({ id: 'chat', worktreeId: 'wt' })] },
|
||||
unifiedTabsByWorktree: {
|
||||
wt: [
|
||||
makeUnifiedTab({
|
||||
id: 'chat',
|
||||
worktreeId: 'wt',
|
||||
groupId: 'group',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
const select = createNativeChatComposerWorkspaceSelector('chat')
|
||||
expect(hasCachedWindowsTerminalCapabilities()).toBe(false)
|
||||
expect(select(store.getState()).machine).toBe('Ubuntu')
|
||||
store.setState({
|
||||
projects: [{ ...project, localWindowsRuntimePreference: { kind: 'inherit-global' } }]
|
||||
})
|
||||
expect(select(store.getState()).machine).toBe('Ubuntu')
|
||||
store.setState({
|
||||
projects: [{ ...project, localWindowsRuntimePreference: { kind: 'windows-host' } }]
|
||||
})
|
||||
expect(select(store.getState()).machine).toBe('Windows PC')
|
||||
expect(
|
||||
createNativeChatComposerWorkspaceSelector('chat', {
|
||||
worktreeId: 'wt',
|
||||
runtimeEnvironmentId: null
|
||||
})(store.getState()).machine
|
||||
).toBe('Windows PC')
|
||||
store.setState({
|
||||
projects: [{ ...project, localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
expect(select(store.getState()).machine).toBe('Windows PC')
|
||||
})
|
||||
|
||||
it('keeps UNC and addressed runtime WSL names', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
repos: [TEST_REPO, { ...TEST_REPO, executionHostId: 'runtime:wsl' }],
|
||||
runtimeEnvironments: [
|
||||
{
|
||||
id: 'wsl',
|
||||
name: 'Windows host',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
endpoints: [],
|
||||
preferredEndpointId: 'direct',
|
||||
orcadDeployment: { kind: 'wsl', distro: 'Debian', localPort: 1234, remotePort: 1234 }
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
[TEST_REPO.id]: [makeWorktree({ id: 'wt', repoId: TEST_REPO.id, hostId: 'runtime:wsl' })]
|
||||
}
|
||||
})
|
||||
expect(
|
||||
createNativeChatComposerWorkspaceSelector('chat', {
|
||||
worktreeId: 'wt',
|
||||
runtimeEnvironmentId: 'wsl'
|
||||
})(store.getState()).machine
|
||||
).toBe('Debian')
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
[TEST_REPO.id]: [
|
||||
makeWorktree({
|
||||
id: 'wt',
|
||||
repoId: TEST_REPO.id,
|
||||
hostId: 'local',
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
|
||||
})
|
||||
]
|
||||
},
|
||||
tabsByWorktree: { wt: [makeTab({ id: 'chat', worktreeId: 'wt' })] },
|
||||
unifiedTabsByWorktree: {
|
||||
wt: [
|
||||
makeUnifiedTab({
|
||||
id: 'chat',
|
||||
worktreeId: 'wt',
|
||||
groupId: 'group',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(createNativeChatComposerWorkspaceSelector('chat')(store.getState()).machine).toBe(
|
||||
'Ubuntu'
|
||||
)
|
||||
})
|
||||
|
||||
it('withholds contested owner bookkeeping instead of showing a local or routing name', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
settings: createGlobalSettingsFixture({ machineName: 'My laptop' }),
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceExecutionHostId: null,
|
||||
repos: [TEST_REPO],
|
||||
worktreesByRepo: {},
|
||||
tabsByWorktree: { wt: [makeTab({ id: 'chat', worktreeId: 'wt' })] },
|
||||
detectedWorktreesByRepo: {
|
||||
[TEST_REPO.id]: makeDetectedResult(TEST_REPO.id, [
|
||||
makeWorktree({ id: 'wt', repoId: TEST_REPO.id, hostId: 'runtime:one' }),
|
||||
makeWorktree({ id: 'wt', repoId: TEST_REPO.id, hostId: 'runtime:two' })
|
||||
])
|
||||
}
|
||||
})
|
||||
const value = createNativeChatComposerWorkspaceSelector('chat')(store.getState())
|
||||
expect(value.hostId).toBeUndefined()
|
||||
expect(value.machine).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([8, 80])(
|
||||
'caches missing-folder resolution before catalog scans for %i retained consumers',
|
||||
(size) => {
|
||||
const store = createTestStore()
|
||||
let visits = 0
|
||||
const folders = Array.from({ length: size }, (_, index) => {
|
||||
const id = `existing-${index}`
|
||||
const folder = makeFolderWorkspace({ id, executionHostId: 'local' })
|
||||
Object.defineProperty(folder, 'id', {
|
||||
get: () => {
|
||||
visits++
|
||||
return id
|
||||
}
|
||||
})
|
||||
return folder
|
||||
})
|
||||
const nested = makeWorktree({
|
||||
id: 'nested',
|
||||
repoId: TEST_REPO.id,
|
||||
hostId: 'ssh:target',
|
||||
runtimeOwnerEnvironmentId: 'hub'
|
||||
})
|
||||
Object.defineProperty(nested, 'path', {
|
||||
get: () => {
|
||||
visits++
|
||||
return '/srv/nested'
|
||||
}
|
||||
})
|
||||
store.setState({
|
||||
settings: createGlobalSettingsFixture({ machineName: 'Old local name' }),
|
||||
folderWorkspaces: folders,
|
||||
repos: [{ ...TEST_REPO, executionHostId: 'ssh:target' }],
|
||||
sshTargetLabels: new Map([['target', 'Client target']]),
|
||||
runtimeEnvironments: [
|
||||
{
|
||||
id: 'hub',
|
||||
name: 'Hub',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
endpoints: [],
|
||||
preferredEndpointId: 'direct'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { [TEST_REPO.id]: [nested] },
|
||||
tabsByWorktree: {
|
||||
nested: [makeTab({ id: 'nested-chat', worktreeId: 'nested' })],
|
||||
'folder:unowned': [makeTab({ id: 'unowned-chat', worktreeId: 'folder:unowned' })]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
nested: [
|
||||
makeUnifiedTab({
|
||||
id: 'nested-chat',
|
||||
worktreeId: 'nested',
|
||||
groupId: 'group',
|
||||
executionHostId: 'ssh:target'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
const selectors = Array.from({ length: size }, (_, index) =>
|
||||
createNativeChatComposerWorkspaceSelector(`chat-${index}`, {
|
||||
worktreeId: `folder:missing-${index}`,
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
)
|
||||
selectors.push(createNativeChatComposerWorkspaceSelector('nested-chat'))
|
||||
selectors.push(createNativeChatComposerWorkspaceSelector('unowned-chat'))
|
||||
const read = () => selectors.map((select) => select(store.getState()))
|
||||
const first = read()
|
||||
expect(visits).toBeGreaterThan(0)
|
||||
visits = 0
|
||||
store.setState({
|
||||
activeWorktreeId: 'unrelated',
|
||||
activeWorkspaceExecutionHostId: 'runtime:box'
|
||||
})
|
||||
read().forEach((value, index) => expect(value).toBe(first[index]))
|
||||
expect(visits).toBe(0)
|
||||
store.setState({
|
||||
settings: createGlobalSettingsFixture({
|
||||
machineName: 'Old local name',
|
||||
activeRuntimeEnvironmentId: 'hub'
|
||||
})
|
||||
})
|
||||
read().forEach((value, index) => expect(value).toBe(first[index]))
|
||||
expect(visits).toBe(0)
|
||||
store
|
||||
.getState()
|
||||
.setEnvironmentRemovedSshTargetLabels('unrelated-server', { target: 'Old target' })
|
||||
read()
|
||||
expect(visits).toBe(0)
|
||||
for (const environmentId of ['unrelated-server', 'hub']) {
|
||||
store.getState().setEnvironmentSshConnectionState(environmentId, 'target', {
|
||||
targetId: 'target',
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
read()
|
||||
store.getState().setEnvironmentSshConnectionState(environmentId, 'target', {
|
||||
targetId: 'target',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
read()
|
||||
}
|
||||
expect(visits).toBe(0)
|
||||
store.setState({
|
||||
settings: createGlobalSettingsFixture({ machineName: 'New local name' }),
|
||||
runtimeEnvironments: store
|
||||
.getState()
|
||||
.runtimeEnvironments.map((entry) => ({ ...entry, name: 'Renamed hub' }))
|
||||
})
|
||||
read()
|
||||
expect(visits).toBe(0)
|
||||
expect(read()[size].machine).toBe('target')
|
||||
store.getState().setEnvironmentRemovedSshTargetLabels('hub', { target: 'Build server' })
|
||||
expect(read()[size].machine).toBe('Build server')
|
||||
store.getState().setEnvironmentRemovedSshTargetLabels('hub', { target: 'Renamed server' })
|
||||
expect(read()[size].machine).toBe('Renamed server')
|
||||
store
|
||||
.getState()
|
||||
.setEnvironmentSshTargetsMetadata('hub', [{ id: 'target', label: 'Current server' }])
|
||||
expect(read()[size].machine).toBe('Current server')
|
||||
store.getState().setEnvironmentSshTargetsMetadata('hub', [])
|
||||
expect(read()[size].machine).toBe('Renamed server')
|
||||
store.getState().removeEnvironmentSshState('hub')
|
||||
expect(read()[size].machine).toBe('target')
|
||||
expect(visits).toBe(0)
|
||||
store.setState({
|
||||
folderWorkspaces: [
|
||||
...folders,
|
||||
makeFolderWorkspace({ id: 'missing-0', folderPath: '/arrived', executionHostId: 'local' })
|
||||
]
|
||||
})
|
||||
expect(read()[0]).toMatchObject({ path: '/arrived' })
|
||||
expect(visits).toBeGreaterThan(0)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -3,7 +3,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner'
|
||||
import type { DraftPasteReadySignal } from './agent-launch/catalog/tui-agent-config-types'
|
||||
import { TUI_AGENT_CONFIG } from './agent-launch/catalog/tui-agent-config'
|
||||
import {
|
||||
COPILOT_STARTUP_PTY_TRACE,
|
||||
COPILOT_TRUST_MENU_PTY_TRACE,
|
||||
@@ -35,10 +34,6 @@ function replay(
|
||||
}
|
||||
|
||||
describe('copilot-composer-prompt startup replay', () => {
|
||||
it('is the signal Copilot launches wait on', () => {
|
||||
expect(TUI_AGENT_CONFIG.copilot.draftPasteReadySignal).toBe('copilot-composer-prompt')
|
||||
})
|
||||
|
||||
it('delivers on the composer row instead of after the loading spinner settles', () => {
|
||||
expect(replay('render-quiet-after-bracketed-paste', COPILOT_STARTUP_PTY_TRACE)).toEqual({
|
||||
readyAtMs: null,
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
NATIVE_CHAT_VISUAL_CSP,
|
||||
NATIVE_CHAT_VISUAL_HOST_CSP,
|
||||
NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE,
|
||||
NATIVE_CHAT_VISUAL_PING_TYPE,
|
||||
NATIVE_CHAT_VISUAL_PONG_TYPE,
|
||||
NATIVE_CHAT_VISUAL_READER_SCROLL_TYPE,
|
||||
NATIVE_CHAT_VISUAL_SIZE_TYPE,
|
||||
buildNativeChatVisualDocument,
|
||||
nativeChatVisualPingMessage,
|
||||
nativeChatVisualThemeCss,
|
||||
readNativeChatVisualFrameMessage,
|
||||
readNativeChatVisualPong
|
||||
@@ -19,59 +16,6 @@ const theme = {
|
||||
tokens: { '--background': 'oklch(0.2 0 0)', '--chart-1': '#123456' }
|
||||
}
|
||||
|
||||
function directives(policy = NATIVE_CHAT_VISUAL_CSP): Map<string, string> {
|
||||
return new Map(
|
||||
policy.split(';').map((part) => {
|
||||
const [name, ...values] = part.trim().split(/\s+/)
|
||||
return [name, values.join(' ')]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('NATIVE_CHAT_VISUAL_CSP', () => {
|
||||
it('denies by default and closes every channel a chart does not need', () => {
|
||||
const policy = directives()
|
||||
expect(policy.get('default-src')).toBe("'none'")
|
||||
for (const name of [
|
||||
'connect-src',
|
||||
'frame-src',
|
||||
'child-src',
|
||||
'worker-src',
|
||||
'object-src',
|
||||
'media-src',
|
||||
'manifest-src',
|
||||
'form-action',
|
||||
'base-uri'
|
||||
]) {
|
||||
expect(policy.get(name)).toBe("'none'")
|
||||
}
|
||||
})
|
||||
|
||||
it('allows assets only inline, from data/blob, or from the pinned CDNs, and never eval', () => {
|
||||
const policy = directives()
|
||||
for (const name of ['script-src', 'style-src']) {
|
||||
expect(policy.get(name)).toContain("'unsafe-inline'")
|
||||
expect(policy.get(name)).not.toContain("'unsafe-eval'")
|
||||
expect(policy.get(name)).not.toContain('*')
|
||||
}
|
||||
expect(policy.get('img-src')).toBe(
|
||||
'data: blob: https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com https://esm.sh https://fonts.googleapis.com https://fonts.gstatic.com'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NATIVE_CHAT_VISUAL_HOST_CSP', () => {
|
||||
it('is the visual policy except that it admits only an about: base, for the shell', () => {
|
||||
const host = directives(NATIVE_CHAT_VISUAL_HOST_CSP)
|
||||
const visual = directives()
|
||||
expect(host.get('base-uri')).toBe('about:')
|
||||
expect(visual.get('base-uri')).toBe("'none'")
|
||||
host.delete('base-uri')
|
||||
visual.delete('base-uri')
|
||||
expect(host).toEqual(visual)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNativeChatVisualDocument', () => {
|
||||
const html = '<!doctype html><html><head><title>x</title></head><body><p>hi</p></body></html>'
|
||||
const document = buildNativeChatVisualDocument({ html, channel: 'abc123', theme })
|
||||
@@ -83,15 +27,6 @@ describe('buildNativeChatVisualDocument', () => {
|
||||
expect(cspAt).toBeLessThan(document.indexOf('<script>'))
|
||||
expect(document.endsWith(html)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves in-page links against the visual itself, ahead of the policy that bars any other base', () => {
|
||||
const baseAt = document.indexOf('<base href="about:srcdoc">')
|
||||
expect(baseAt).toBeGreaterThan(0)
|
||||
expect(baseAt).toBeLessThan(document.indexOf('http-equiv="Content-Security-Policy"'))
|
||||
// A srcdoc page otherwise resolves '#x' against Orca's own URL and navigates away.
|
||||
expect(new URL('#overview', 'about:srcdoc').href).toBe('about:srcdoc#overview')
|
||||
expect(() => new URL('page-2.html', 'about:srcdoc')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatVisualThemeCss', () => {
|
||||
@@ -113,32 +48,6 @@ describe('nativeChatVisualThemeCss', () => {
|
||||
})
|
||||
|
||||
describe('readNativeChatVisualFrameMessage', () => {
|
||||
it('accepts a finite size and an http(s) link on its own channel', () => {
|
||||
expect(
|
||||
readNativeChatVisualFrameMessage(
|
||||
{ type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: 321.5 },
|
||||
'c'
|
||||
)
|
||||
).toEqual({ kind: 'size', height: 321.5 })
|
||||
expect(
|
||||
readNativeChatVisualFrameMessage(
|
||||
{ type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'https://example.com/a b' },
|
||||
'c'
|
||||
)
|
||||
).toEqual({ kind: 'open-link', url: 'https://example.com/a%20b' })
|
||||
})
|
||||
|
||||
it('accepts a wheel with a finite nonzero delta and a touch drag', () => {
|
||||
const scroll = { type: NATIVE_CHAT_VISUAL_READER_SCROLL_TYPE, channel: 'c' }
|
||||
expect(
|
||||
readNativeChatVisualFrameMessage({ ...scroll, input: 'wheel', deltaY: -30 }, 'c')
|
||||
).toEqual({ kind: 'reader-scroll', input: 'wheel', deltaY: -30 })
|
||||
expect(readNativeChatVisualFrameMessage({ ...scroll, input: 'touch' }, 'c')).toEqual({
|
||||
kind: 'reader-scroll',
|
||||
input: 'touch'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['another channel', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'x', height: 10 }],
|
||||
['no channel', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, height: 10 }],
|
||||
@@ -189,16 +98,6 @@ describe('readNativeChatVisualFrameMessage', () => {
|
||||
})
|
||||
|
||||
describe('readNativeChatVisualPong', () => {
|
||||
it('reads the ping id on its own channel', () => {
|
||||
const pong = { type: NATIVE_CHAT_VISUAL_PONG_TYPE, channel: 'c', id: 2 }
|
||||
expect(readNativeChatVisualPong(pong, 'c')).toBe(2)
|
||||
expect(nativeChatVisualPingMessage(2, 'c')).toEqual({
|
||||
type: NATIVE_CHAT_VISUAL_PING_TYPE,
|
||||
channel: 'c',
|
||||
id: 2
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['another channel', { type: NATIVE_CHAT_VISUAL_PONG_TYPE, channel: 'x', id: 2 }],
|
||||
['a ping', { type: NATIVE_CHAT_VISUAL_PING_TYPE, channel: 'c', id: 2 }],
|
||||
@@ -210,14 +109,3 @@ describe('readNativeChatVisualPong', () => {
|
||||
expect(readNativeChatVisualPong(data, 'c')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps ordinary themed file guests scrollable without the inline containment shell', async () => {
|
||||
const { nativeChatVisualGuestTheme } = await import('./native-chat-visual-shell')
|
||||
const theme = nativeChatVisualGuestTheme({
|
||||
colorScheme: 'dark',
|
||||
tokens: { '--background': '#111' }
|
||||
})
|
||||
expect(theme.css).toContain('--background:#111')
|
||||
expect(theme.css).not.toContain('scrollbar')
|
||||
expect(theme.css).not.toContain('overflow:hidden')
|
||||
})
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
foldSubagentItems,
|
||||
namedSubagents,
|
||||
subagentFallbackText
|
||||
} from './native-chat-subagent-items'
|
||||
import { foldSubagentItems, subagentFallbackText } from './native-chat-subagent-items'
|
||||
import { projectNativeChatTranscriptMessages } from './native-chat-transcript-projection'
|
||||
import { formatWorkerTranscriptMessage } from '../../worker-transcript-text'
|
||||
import type {
|
||||
NativeChatMessage,
|
||||
NativeChatSubagentBlock,
|
||||
@@ -85,37 +80,6 @@ describe('a turn’s subagents drawn as one row', () => {
|
||||
expect(new Set(rows.map((row) => groupOf(row).groupId)).size).toBe(3)
|
||||
})
|
||||
|
||||
it('tells same-named siblings apart without taking a name a sibling already has', () => {
|
||||
const rows = foldSubagentItems([
|
||||
item(1, subagent('a', { label: 'Audit' })),
|
||||
item(2, subagent('b', { label: 'Audit' })),
|
||||
item(3, subagent('c', { label: 'Audit 2' })),
|
||||
item(4, subagent('d', { label: 'Audit' })),
|
||||
item(5, subagent('e', { label: 'Audit 4' }))
|
||||
])
|
||||
|
||||
expect(groupOf(rows[0]).agents.map((agent) => agent.label)).toEqual([
|
||||
'Audit',
|
||||
'Audit 3',
|
||||
'Audit 2',
|
||||
'Audit 5',
|
||||
'Audit 4'
|
||||
])
|
||||
})
|
||||
|
||||
it('hands back the same row while no sibling changed, and a new one when one did', () => {
|
||||
const a = item(1, subagent('a'))
|
||||
const b = item(2, subagent('b'))
|
||||
const first = foldSubagentItems([a, b])[0]
|
||||
expect(foldSubagentItems([a, b])[0]).toBe(first)
|
||||
|
||||
const settled = item(2, subagent('b', { state: 'completed' }))
|
||||
const next = foldSubagentItems([a, settled])[0]
|
||||
expect(next).not.toBe(first)
|
||||
// The sibling that did not change keeps its line, so its memoized row stays put.
|
||||
expect(groupOf(next).agents[0]).toBe(groupOf(first).agents[0])
|
||||
})
|
||||
|
||||
it('leaves an older host’s roster row, and rows that are not one subagent, as they came', () => {
|
||||
const roster: NativeChatMessage = {
|
||||
...text(1, 'system', 'Ran 1 subagent'),
|
||||
@@ -154,41 +118,3 @@ describe('a turn’s subagents drawn as one row', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the sentence an older client draws', () => {
|
||||
it('claims no liveness it cannot keep, and names an adverse outcome', () => {
|
||||
expect(subagentFallbackText(subagent('a', { label: 'Audit' }))).toBe(
|
||||
'Kicked off subagent Audit'
|
||||
)
|
||||
expect(subagentFallbackText(subagent('a', { label: 'Audit', state: 'completed' }))).toBe(
|
||||
'Ran subagent Audit'
|
||||
)
|
||||
expect(subagentFallbackText(subagent('a', { label: 'Audit', state: 'failed' }))).toBe(
|
||||
'Ran subagent Audit (failed)'
|
||||
)
|
||||
})
|
||||
|
||||
it('is what a text surface prints, once, and the block alone still prints it', () => {
|
||||
const own = item(1, subagent('a', { label: 'Audit' }))
|
||||
expect(formatWorkerTranscriptMessage(own)).toBe('[system] Kicked off subagent Audit')
|
||||
expect(formatWorkerTranscriptMessage({ ...own, blocks: own.blocks.slice(1) })).toBe(
|
||||
'[system] [subagents] Kicked off subagent Audit'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the subagents a row names', () => {
|
||||
it('reads an older host’s roster and a subagent’s own row alike', () => {
|
||||
expect(
|
||||
namedSubagents(subagent('a', { run: 'r', outcomeBasis: 'stop-acknowledged', tokens: 3 }))
|
||||
).toEqual([{ id: 'a', label: 'a', state: 'working', tokens: 3 }])
|
||||
expect(
|
||||
namedSubagents({
|
||||
type: 'subagent-group',
|
||||
groupId: 'g',
|
||||
agents: [{ id: 'b', label: 'b', state: 'idle' }]
|
||||
})
|
||||
).toEqual([{ id: 'b', label: 'b', state: 'idle' }])
|
||||
expect(namedSubagents({ type: 'text', text: 'x' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,15 +21,6 @@ function row(overrides: Partial<NativeChatTurnFoldRow> = {}): NativeChatTurnFold
|
||||
}
|
||||
}
|
||||
|
||||
/** The turn in the reported session: a prompt, narration, work, then the answer. */
|
||||
const TURN: NativeChatTurnFoldRow[] = [
|
||||
row({ role: 'user' }),
|
||||
row(),
|
||||
row({ rendersProse: false }),
|
||||
row(),
|
||||
row()
|
||||
]
|
||||
|
||||
/** An error-toned system row: the agent or host reporting the turn failed. */
|
||||
const FAILURE = row({ role: 'system', reportsFailure: true })
|
||||
|
||||
@@ -105,57 +96,8 @@ describe('rendered plans in collapsed turns', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatTurnAnswerRows', () => {
|
||||
it('ignores reasoning and system rows, which are never the agent answering', () => {
|
||||
const rows = [row({ role: 'user' }), row(), row({ role: 'reasoning' }), row({ role: 'system' })]
|
||||
expect([...nativeChatTurnAnswerRows(rows)]).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatTurnFold', () => {
|
||||
it('folds a settled turn to its answer', () => {
|
||||
const { foldedRows } = nativeChatTurnFold({
|
||||
rows: TURN,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect([...foldedRows].sort()).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
// A stored-only provider event draws nothing: a disclosure over it would open onto nothing.
|
||||
it('offers no disclosure for a turn whose only other row draws nothing', () => {
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows: [
|
||||
row({ role: 'user' }),
|
||||
row({ role: 'system', rendersProse: false, draws: false }),
|
||||
row()
|
||||
],
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
expect(foldableTurnKeys.size).toBe(0)
|
||||
})
|
||||
|
||||
it('folds nothing while the turn is still running', () => {
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows: TURN,
|
||||
settledTurnKeys: NONE,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
expect(foldableTurnKeys.size).toBe(0)
|
||||
})
|
||||
|
||||
it('reveals every row of a turn the reader opened, and still reports it foldable', () => {
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows: TURN,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: SETTLED
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
expect([...foldableTurnKeys]).toEqual(['turn-1'])
|
||||
})
|
||||
|
||||
it('keeps a spawn roster or background task out of the fold', () => {
|
||||
const rows = [row({ role: 'user' }), row(), row({ outlivesTurn: true }), row()]
|
||||
@@ -168,28 +110,6 @@ describe('nativeChatTurnFold', () => {
|
||||
expect(foldedRows.has(1)).toBe(true)
|
||||
})
|
||||
|
||||
it('folds a prose-less turn whole, so its commands do not return to the transcript', () => {
|
||||
const rows = [row({ role: 'user' }), row({ rendersProse: false }), row({ rendersProse: false })]
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect([...foldedRows].sort()).toEqual([1, 2])
|
||||
expect([...foldableTurnKeys]).toEqual(['turn-1'])
|
||||
})
|
||||
|
||||
it('offers no disclosure on a turn that is nothing but its answer', () => {
|
||||
const rows = [row({ role: 'user' }), row()]
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
expect(foldableTurnKeys.size).toBe(0)
|
||||
})
|
||||
|
||||
it('folds each settled turn to its own answer and leaves a running turn alone', () => {
|
||||
const rows = [
|
||||
row({ turnKey: 'turn-1', role: 'user' }),
|
||||
@@ -207,16 +127,6 @@ describe('nativeChatTurnFold', () => {
|
||||
expect([...foldedRows]).toEqual([1])
|
||||
})
|
||||
|
||||
it('leaves rows before the first prompt alone', () => {
|
||||
const rows = [row({ turnKey: undefined }), row({ turnKey: undefined })]
|
||||
const { foldedRows } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
})
|
||||
|
||||
it('ends a failed turn on its error, folding the work and prose before it', () => {
|
||||
const rows = [row({ role: 'user' }), row(), row({ rendersProse: false }), FAILURE]
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
@@ -238,17 +148,6 @@ describe('nativeChatTurnFold', () => {
|
||||
expect([...foldedRows].sort()).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('shows a turn that is nothing but its error as that error, with no disclosure', () => {
|
||||
const rows = [row({ role: 'user' }), FAILURE]
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.size).toBe(0)
|
||||
expect(foldableTurnKeys.size).toBe(0)
|
||||
})
|
||||
|
||||
it('never folds a compaction report, and keeps the last prose as the answer', () => {
|
||||
const rows = [
|
||||
row({ role: 'user' }),
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
NESTED_WORKER_MAX_DEPTH_DEFAULT,
|
||||
migrateNestedWorkerMaxDepthDefault,
|
||||
nestedWorkerDepthExceededMessage,
|
||||
resolveNestedWorkerMaxDepth
|
||||
} from './nested-worker-depth'
|
||||
|
||||
describe('resolveNestedWorkerMaxDepth', () => {
|
||||
it('defaults to 4 when unset', () => {
|
||||
expect(resolveNestedWorkerMaxDepth(undefined)).toBe(4)
|
||||
expect(resolveNestedWorkerMaxDepth(null)).toBe(4)
|
||||
expect(resolveNestedWorkerMaxDepth({})).toBe(4)
|
||||
})
|
||||
|
||||
it('accepts whole numbers at or above 1', () => {
|
||||
expect(resolveNestedWorkerMaxDepth({ nestedWorkerMaxDepth: 1 })).toBe(1)
|
||||
expect(resolveNestedWorkerMaxDepth({ nestedWorkerMaxDepth: 3 })).toBe(3)
|
||||
})
|
||||
|
||||
// Malformed settings must fall back to the cap, never disable nesting limits.
|
||||
it.each([
|
||||
['a numeric string', '2'],
|
||||
['a boolean', true],
|
||||
['zero', 0],
|
||||
['negative', -1],
|
||||
['fractional', 1.5],
|
||||
['NaN', Number.NaN],
|
||||
['Infinity', Number.POSITIVE_INFINITY],
|
||||
['unsafe integer', Number.MAX_SAFE_INTEGER + 1],
|
||||
['null', null]
|
||||
])('falls back to the default for %s', (_label, value) => {
|
||||
expect(resolveNestedWorkerMaxDepth({ nestedWorkerMaxDepth: value as unknown as number })).toBe(
|
||||
NESTED_WORKER_MAX_DEPTH_DEFAULT
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrateNestedWorkerMaxDepthDefault', () => {
|
||||
it('moves the old default of 1 to 4 once', () => {
|
||||
expect(migrateNestedWorkerMaxDepthDefault({ nestedWorkerMaxDepth: 1 })).toEqual({
|
||||
nestedWorkerMaxDepth: 4,
|
||||
nestedWorkerMaxDepthDefaultedToFour: true
|
||||
})
|
||||
expect(migrateNestedWorkerMaxDepthDefault(undefined).nestedWorkerMaxDepth).toBe(4)
|
||||
})
|
||||
|
||||
it('keeps a deliberate value above 1', () => {
|
||||
expect(
|
||||
migrateNestedWorkerMaxDepthDefault({ nestedWorkerMaxDepth: 2 }).nestedWorkerMaxDepth
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps 1 once the guard is stamped', () => {
|
||||
expect(
|
||||
migrateNestedWorkerMaxDepthDefault({
|
||||
nestedWorkerMaxDepth: 1,
|
||||
nestedWorkerMaxDepthDefaultedToFour: true
|
||||
}).nestedWorkerMaxDepth
|
||||
).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('depth-exceeded message', () => {
|
||||
it('names the cap and the refused depth, and says to finish here', () => {
|
||||
const message = nestedWorkerDepthExceededMessage(5, 4)
|
||||
expect(message).toContain('at most 4 levels deep')
|
||||
expect(message).toContain('(depth 5)')
|
||||
expect(message).toContain('Finish this task here')
|
||||
})
|
||||
})
|
||||
@@ -1,114 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseManualNetworkAddress } from './manual-address'
|
||||
import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../remote-runtime/pairing/mobile-pairing-protocol-limits'
|
||||
|
||||
describe('parseManualNetworkAddress', () => {
|
||||
describe('IPv4', () => {
|
||||
it('accepts canonical IPv4', () => {
|
||||
expect(parseManualNetworkAddress('192.168.1.24')).toEqual({
|
||||
ok: true,
|
||||
address: '192.168.1.24'
|
||||
})
|
||||
expect(parseManualNetworkAddress('100.64.1.20')).toEqual({
|
||||
ok: true,
|
||||
address: '100.64.1.20'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the upper boundary and rejects the wildcard address', () => {
|
||||
expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(false)
|
||||
expect(parseManualNetworkAddress('255.255.255.255').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts an IPv4 address with a port suffix', () => {
|
||||
expect(parseManualNetworkAddress('192.168.1.24:8080')).toEqual({
|
||||
ok: true,
|
||||
address: '192.168.1.24:8080'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed IPv4', () => {
|
||||
for (const bad of ['', ' ', '1.2.3', '1.2.3.4.5', '256.0.0.1']) {
|
||||
expect(parseManualNetworkAddress(bad)).toEqual({
|
||||
ok: false,
|
||||
error: 'Enter an IPv4/IPv6 address, hostname, or HTTP(S)/WebSocket URL'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects leading zeros in octets', () => {
|
||||
expect(parseManualNetworkAddress('01.02.03.04')).toEqual({
|
||||
ok: false,
|
||||
error: 'Enter an IPv4/IPv6 address, hostname, or HTTP(S)/WebSocket URL'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tailscale MagicDNS hostname', () => {
|
||||
it('accepts short MagicDNS names', () => {
|
||||
expect(parseManualNetworkAddress('my-mac.ts.net')).toEqual({
|
||||
ok: true,
|
||||
address: 'my-mac.ts.net'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts tailnet-qualified MagicDNS names', () => {
|
||||
expect(parseManualNetworkAddress('my-mac.tail-abcd.ts.net')).toEqual({
|
||||
ok: true,
|
||||
address: 'my-mac.tail-abcd.ts.net'
|
||||
})
|
||||
expect(parseManualNetworkAddress('a.b.c.d.ts.net').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(parseManualNetworkAddress('MY-MAC.TS.NET').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a MagicDNS-shaped hostname with a malformed label', () => {
|
||||
// RFC 1123 forbids leading/trailing label hyphens even under ts.net.
|
||||
expect(parseManualNetworkAddress('-foo.ts.net').ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('arbitrary hostnames (DDNS / self-hosted domains)', () => {
|
||||
it('accepts a bare single-label hostname', () => {
|
||||
expect(parseManualNetworkAddress('my-nas')).toEqual({
|
||||
ok: true,
|
||||
address: 'my-nas'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a multi-label domain, e.g. a DDNS hostname', () => {
|
||||
expect(parseManualNetworkAddress('home.example.com')).toEqual({
|
||||
ok: true,
|
||||
address: 'home.example.com'
|
||||
})
|
||||
expect(parseManualNetworkAddress('my-house.ddns.net').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a hostname with a port suffix', () => {
|
||||
expect(parseManualNetworkAddress('home.example.com:8443')).toEqual({
|
||||
ok: true,
|
||||
address: 'home.example.com:8443'
|
||||
})
|
||||
})
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(parseManualNetworkAddress('HOME.EXAMPLE.COM').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects labels with leading or trailing hyphens', () => {
|
||||
for (const bad of ['-foo.example.com', 'foo-.example.com', 'foo..com']) {
|
||||
expect(parseManualNetworkAddress(bad).ok).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects hostnames with characters outside the RFC 1123 grammar', () => {
|
||||
for (const bad of ['my_host.example.com', 'my host.example.com', 'example.com/path']) {
|
||||
expect(parseManualNetworkAddress(bad).ok).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a host whose last label is numeric as an ambiguous IPv4', () => {
|
||||
// WHATWG URLs reinterpret numeric endings as IPv4 (123 → 0.0.0.123) or fail; accepting them would validate a host main dials differently.
|
||||
const bad = [
|
||||
@@ -126,62 +20,16 @@ describe('parseManualNetworkAddress', () => {
|
||||
expect(parseManualNetworkAddress(value).ok).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('still accepts a hostname whose last label merely contains digits', () => {
|
||||
// Only wholly numeric/hex endings are ambiguous; nas1 remains a hostname.
|
||||
expect(parseManualNetworkAddress('host2.example.com').ok).toBe(true)
|
||||
expect(parseManualNetworkAddress('my-nas1').ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('port suffix', () => {
|
||||
it('accepts boundary port values', () => {
|
||||
expect(parseManualNetworkAddress('example.com:1').ok).toBe(true)
|
||||
expect(parseManualNetworkAddress('example.com:65535').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects out-of-range ports', () => {
|
||||
expect(parseManualNetworkAddress('example.com:0').ok).toBe(false)
|
||||
expect(parseManualNetworkAddress('example.com:65536').ok).toBe(false)
|
||||
expect(parseManualNetworkAddress('example.com:99999').ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a non-numeric or empty port', () => {
|
||||
expect(parseManualNetworkAddress('example.com:abc').ok).toBe(false)
|
||||
expect(parseManualNetworkAddress('example.com:').ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a leading-zero (non-canonical, unbounded-length) port', () => {
|
||||
expect(parseManualNetworkAddress('example.com:0080').ok).toBe(false)
|
||||
expect(parseManualNetworkAddress(`example.com:${'0'.repeat(1000)}8080`).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed addresses with more than one colon', () => {
|
||||
expect(parseManualNetworkAddress('example.com:80:90').ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('IPv6', () => {
|
||||
it('accepts bare and bracketed IPv6 addresses with optional ports', () => {
|
||||
expect(parseManualNetworkAddress('::1')).toEqual({ ok: true, address: '::1' })
|
||||
expect(parseManualNetworkAddress('2001:db8::4')).toEqual({
|
||||
ok: true,
|
||||
address: '2001:db8::4'
|
||||
})
|
||||
expect(parseManualNetworkAddress('0:0:0:0:0:0:0:1')).toEqual({
|
||||
ok: true,
|
||||
address: '0:0:0:0:0:0:0:1'
|
||||
})
|
||||
expect(parseManualNetworkAddress('[2001:db8::4]:7443')).toEqual({
|
||||
ok: true,
|
||||
address: '[2001:db8::4]:7443'
|
||||
})
|
||||
expect(parseManualNetworkAddress('[0:0:0:0:0:0:0:1]:7443')).toEqual({
|
||||
ok: true,
|
||||
address: '[0:0:0:0:0:0:0:1]:7443'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects wildcard, malformed, and invalid-port IPv6 addresses', () => {
|
||||
for (const bad of [
|
||||
'::',
|
||||
@@ -197,39 +45,9 @@ describe('parseManualNetworkAddress', () => {
|
||||
expect(parseManualNetworkAddress(bad).ok).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts reachable IPv4-mapped and native IPv6 controls', () => {
|
||||
expect(parseManualNetworkAddress('::ffff:192.168.1.24').ok).toBe(true)
|
||||
expect(parseManualNetworkAddress('[::ffff:192.168.1.24]:8080').ok).toBe(true)
|
||||
expect(parseManualNetworkAddress('2001:db8::24').ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('full URL', () => {
|
||||
it('accepts ws:// and wss:// URLs with optional ports', () => {
|
||||
expect(parseManualNetworkAddress('wss://example.com')).toEqual({
|
||||
ok: true,
|
||||
address: 'wss://example.com'
|
||||
})
|
||||
expect(parseManualNetworkAddress('wss://example.com:443')).toEqual({
|
||||
ok: true,
|
||||
address: 'wss://example.com:443'
|
||||
})
|
||||
expect(parseManualNetworkAddress('ws://example.com:8080')).toEqual({
|
||||
ok: true,
|
||||
address: 'ws://example.com:8080'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts reverse-proxy paths and query strings', () => {
|
||||
expect(parseManualNetworkAddress('wss://example.com/orca?route=runtime').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts HTTP URLs that the main process normalizes to WebSocket URLs', () => {
|
||||
expect(parseManualNetworkAddress('http://example.com/orca').ok).toBe(true)
|
||||
expect(parseManualNetworkAddress('https://example.com/orca?route=runtime').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects URLs the pairing endpoint cannot advertise', () => {
|
||||
for (const bad of [
|
||||
'ftp://example.com',
|
||||
@@ -246,30 +64,4 @@ describe('parseManualNetworkAddress', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('length and whitespace', () => {
|
||||
it('matches the shared pairing endpoint length boundary', () => {
|
||||
const prefix = 'wss://example.com/'
|
||||
const atLimit = `${prefix}${'a'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS - prefix.length)}`
|
||||
const aboveLimit = `${atLimit}a`
|
||||
|
||||
expect(atLimit).toHaveLength(PAIRING_ENDPOINT_MAX_CHARACTERS)
|
||||
expect(parseManualNetworkAddress(atLimit)).toEqual({ ok: true, address: atLimit })
|
||||
expect(aboveLimit).toHaveLength(PAIRING_ENDPOINT_MAX_CHARACTERS + 1)
|
||||
expect(parseManualNetworkAddress(aboveLimit).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects inputs longer than 253 chars', () => {
|
||||
const long = `${'a'.repeat(250)}.ts.net`
|
||||
expect(long.length).toBeGreaterThan(253)
|
||||
expect(parseManualNetworkAddress(long).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('trims leading and trailing whitespace before validating', () => {
|
||||
expect(parseManualNetworkAddress(' 192.168.1.24 ')).toEqual({
|
||||
ok: true,
|
||||
address: '192.168.1.24'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../remote-runtime/pairing/mobile-pairing-protocol-limits'
|
||||
import { parseServerShareAddress } from './server-share-address'
|
||||
|
||||
describe('parseServerShareAddress', () => {
|
||||
it('accepts a bare hostname or IP', () => {
|
||||
expect(parseServerShareAddress('my-host')).toEqual({ ok: true, value: 'my-host' })
|
||||
expect(parseServerShareAddress('my-mac.tail-abcd.ts.net')).toEqual({
|
||||
ok: true,
|
||||
value: 'my-mac.tail-abcd.ts.net'
|
||||
})
|
||||
expect(parseServerShareAddress('192.168.1.50')).toEqual({
|
||||
ok: true,
|
||||
value: '192.168.1.50'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty, whitespace-containing, and malformed input', () => {
|
||||
for (const bad of [
|
||||
'',
|
||||
' ',
|
||||
'has space',
|
||||
'http://my-host',
|
||||
'wss://',
|
||||
':6768',
|
||||
'0.0.0.0',
|
||||
'::',
|
||||
'my-host:0',
|
||||
'999.999.999.999',
|
||||
'wss://user:password@my-host'
|
||||
]) {
|
||||
expect(parseServerShareAddress(bad).ok).toBe(false)
|
||||
}
|
||||
expect(parseServerShareAddress('a'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS + 1)).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,35 +4,6 @@ import { openCodeVariantRetryPlan } from './opencode-generation-command'
|
||||
|
||||
const rejection = 'Unrecognized flag: --variant in command opencode run'
|
||||
describe('OpenCode generation commands', () => {
|
||||
it.each(['opencode', 'opencode.exe', 'opencode.cmd', 'opencode2'])(
|
||||
'keeps run before launch flags for %s',
|
||||
(binary) => {
|
||||
const result = planCommitMessageGeneration(
|
||||
{ agentId: 'opencode', model: 'default', agentCommandOverride: `${binary} --auto` },
|
||||
'PROMPT'
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
plan: {
|
||||
args: ['run', '--auto', '--agent', 'build', '--format', 'json'],
|
||||
stdinPayload: 'PROMPT',
|
||||
outputFormat: 'opencode-json'
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps explicit formatted output recipes on their requested output format', () => {
|
||||
const result = planCommitMessageGeneration(
|
||||
{ agentId: 'opencode', model: 'default', agentArgs: '--format default' },
|
||||
'PROMPT'
|
||||
)
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
expect(result.plan.outputFormat).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retries only the precise v2 argv rejection, retaining the prompt and selected model', () => {
|
||||
const result = planCommitMessageGeneration(
|
||||
{ agentId: 'opencode', model: 'fixture/chat', thinkingLevel: 'high' },
|
||||
@@ -48,24 +19,6 @@ describe('OpenCode generation commands', () => {
|
||||
expect(openCodeVariantRetryPlan(result.plan, 'provider rejected the request')).toBeNull()
|
||||
expect(openCodeVariantRetryPlan(result.plan, 'Unrecognized flag: --other')).toBeNull()
|
||||
})
|
||||
it.each([
|
||||
'--model=fixture/chat --variant=high',
|
||||
'-mfixture/chat --variant=high',
|
||||
'--variant high --model=fixture/chat',
|
||||
'--variant=high -m fixture/chat'
|
||||
])('retries the chosen recipe model and variant in %s', (agentArgs) => {
|
||||
const result = planCommitMessageGeneration(
|
||||
{ agentId: 'opencode', model: 'other/model', thinkingLevel: 'low', agentArgs },
|
||||
'PROMPT'
|
||||
)
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
const retry = openCodeVariantRetryPlan(result.plan, rejection)
|
||||
expect(retry?.args.join(' ')).toContain('fixture/chat#high')
|
||||
expect(retry?.args.join(' ')).not.toContain('--variant')
|
||||
expect(retry?.stdinPayload).toBe('PROMPT')
|
||||
})
|
||||
|
||||
it('does not treat prompt tokens after the terminator as options', () => {
|
||||
expect(
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
bindHostIsNetworkExposed,
|
||||
ORCAD_LOOPBACK_BIND_HOST,
|
||||
OrcadBindAddressError,
|
||||
resolveOrcadBindHost
|
||||
} from './orcad-bind-address'
|
||||
import { OrcadBindAddressError, resolveOrcadBindHost } from './orcad-bind-address'
|
||||
|
||||
describe('resolveOrcadBindHost', () => {
|
||||
it('defaults to loopback when the operator asked for nothing', () => {
|
||||
expect(resolveOrcadBindHost()).toBe(ORCAD_LOOPBACK_BIND_HOST)
|
||||
expect(ORCAD_LOOPBACK_BIND_HOST).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it('accepts literal IPv4 and IPv6 addresses, including explicit wide binds', () => {
|
||||
expect(resolveOrcadBindHost('0.0.0.0')).toBe('0.0.0.0')
|
||||
expect(resolveOrcadBindHost('10.1.2.3')).toBe('10.1.2.3')
|
||||
expect(resolveOrcadBindHost('::1')).toBe('::1')
|
||||
expect(resolveOrcadBindHost('localhost')).toBe('127.0.0.1')
|
||||
expect(resolveOrcadBindHost(' 127.0.0.1 ')).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it('refuses hostnames, because DNS would decide which interface got bound', () => {
|
||||
expect(() => resolveOrcadBindHost('internal.example')).toThrow(OrcadBindAddressError)
|
||||
expect(() => resolveOrcadBindHost('')).toThrow(OrcadBindAddressError)
|
||||
@@ -29,14 +11,3 @@ describe('resolveOrcadBindHost', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bindHostIsNetworkExposed', () => {
|
||||
it('separates local-only addresses from network-reachable ones', () => {
|
||||
expect(bindHostIsNetworkExposed('127.0.0.1')).toBe(false)
|
||||
expect(bindHostIsNetworkExposed('127.5.5.5')).toBe(false)
|
||||
expect(bindHostIsNetworkExposed('::1')).toBe(false)
|
||||
expect(bindHostIsNetworkExposed('0.0.0.0')).toBe(true)
|
||||
expect(bindHostIsNetworkExposed('::')).toBe(true)
|
||||
expect(bindHostIsNetworkExposed('10.1.2.3')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -110,14 +110,6 @@ describe('orcad migration manifest contract', () => {
|
||||
})
|
||||
|
||||
// Retired draft-blob field from development builds; no released desktop sent it.
|
||||
it('accepts and drops the retired editorDraftsBlob declaration', () => {
|
||||
const dormantState = {
|
||||
...dormantLineage('folder:folder-1', 'worktree:repo-1::/srv/repo-1'),
|
||||
editorDraftsBlob: { sha256: digest, byteLength: 4, entries: [] }
|
||||
}
|
||||
const parsed = parseOrcadMigrationManifest(manifest({ dormantState }))
|
||||
expect(parsed.payload.dormantState).not.toHaveProperty('editorDraftsBlob')
|
||||
})
|
||||
|
||||
it('rejects a malformed workspace key rather than treating it as a worktree id', () => {
|
||||
expect(() =>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from '../workspace-session/client-hosted-browser-page-record'
|
||||
import type { BrowserWorkspace } from '../browser-workspace-types'
|
||||
import { assertOrcadMigrationDormantWorkspaceSessionReferences } from './orcad-migration-dormant-session-validation'
|
||||
import { parseOrcadMigrationClientState } from './orcad-migration-client-state'
|
||||
import type { WorkspaceSessionState } from '../workspace-session/workspace-session-state-types'
|
||||
|
||||
const OWNER = 'repo-1::/srv/worktree'
|
||||
@@ -62,10 +61,6 @@ function sessionWithPages(pages: ReturnType<typeof clientPage>[]): WorkspaceSess
|
||||
}
|
||||
|
||||
describe('dormant client-hosted browser page migration validation', () => {
|
||||
it('accepts held pages whose workspace id belongs to the transferred browser workspace', () => {
|
||||
expect(() => validate(sessionWithPages([clientPage('page-1')]))).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a page that names a browser workspace outside its owner', () => {
|
||||
expect(() => validate(sessionWithPages([clientPage('page-1', 'browser-missing')]))).toThrow(
|
||||
'orcad_migration_dormant_workspace_session_client_page_scope_invalid'
|
||||
@@ -78,52 +73,6 @@ describe('dormant client-hosted browser page migration validation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts scoped client-owned close intents alongside transferred pages', () => {
|
||||
const session = sessionWithPages([clientPage('page-1')])
|
||||
session.clientHostedBrowserCloseIntentsByEnvironment = {
|
||||
'old-environment': [{ browserPageId: 'page-1', worktreeId: OWNER, closedAt: 10 }]
|
||||
}
|
||||
expect(() => validate(session)).not.toThrow()
|
||||
})
|
||||
|
||||
it('parses migration close-intent envelopes with source environment identity', () => {
|
||||
expect(
|
||||
parseOrcadMigrationClientState({
|
||||
clientHostedBrowserCloseIntents: [
|
||||
{
|
||||
sourceEnvironmentId: 'old-environment',
|
||||
browserPageId: 'page-1',
|
||||
worktreeId: OWNER,
|
||||
closedAt: 10
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
clientHostedBrowserCloseIntents: [
|
||||
{
|
||||
sourceEnvironmentId: 'old-environment',
|
||||
browserPageId: 'page-1',
|
||||
worktreeId: OWNER,
|
||||
closedAt: 10
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('allows global browser URL history to remain client-owned', () => {
|
||||
const session = sessionWithPages([])
|
||||
session.browserUrlHistory = [
|
||||
{
|
||||
url: 'https://example.test/',
|
||||
normalizedUrl: 'https://example.test/',
|
||||
title: 'Example',
|
||||
lastVisitedAt: 10,
|
||||
visitCount: 1
|
||||
}
|
||||
]
|
||||
expect(() => validate(session)).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts markdown visibility only when it names a persisted open file', () => {
|
||||
const session = sessionWithPages([])
|
||||
session.openFilesByWorktree = {
|
||||
|
||||
@@ -2,32 +2,15 @@ import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../constants'
|
||||
import {
|
||||
assertOrcadMigrationScrollbackReferences,
|
||||
MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS,
|
||||
OrcadMigrationSnapshotChunkRequestSchema,
|
||||
parseOrcadMigrationSnapshotChunkResult,
|
||||
parseOrcadMigrationTerminalScrollbackSnapshots,
|
||||
type OrcadMigrationSnapshotChunkRequest,
|
||||
type OrcadMigrationTerminalScrollbackSnapshot
|
||||
} from './orcad-migration-scrollback'
|
||||
import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../terminal/replay/terminal-scrollback-limits'
|
||||
|
||||
const DIGEST = 'a'.repeat(64)
|
||||
|
||||
describe('orcad migration scrollback wire validation', () => {
|
||||
it('accepts only canonical non-empty bounded base64 chunks', () => {
|
||||
const request = chunkRequest('YQ==')
|
||||
expect(OrcadMigrationSnapshotChunkRequestSchema.safeParse(request).success).toBe(true)
|
||||
expect(
|
||||
OrcadMigrationSnapshotChunkRequestSchema.safeParse({ ...request, futureField: 1 }).success,
|
||||
'a newer client field'
|
||||
).toBe(true)
|
||||
for (const bytesBase64 of ['', 'YQ', 'YQ=', 'A===', '***=']) {
|
||||
expect(
|
||||
OrcadMigrationSnapshotChunkRequestSchema.safeParse({ ...request, bytesBase64 }).success
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('requires an acknowledgment for exactly the decoded chunk length', () => {
|
||||
const request = chunkRequest(Buffer.from('abc').toString('base64'), 7)
|
||||
expect(
|
||||
@@ -69,29 +52,6 @@ describe('orcad migration scrollback wire validation', () => {
|
||||
).toThrow('orcad_migration_dormant_scrollback_snapshot_leaf_duplicate')
|
||||
})
|
||||
|
||||
it('enforces descriptor count, per-file, and aggregate limits', () => {
|
||||
expect(() =>
|
||||
parseOrcadMigrationTerminalScrollbackSnapshots(
|
||||
Array.from({ length: MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS + 1 }, (_, index) =>
|
||||
descriptor(index + 1)
|
||||
)
|
||||
)
|
||||
).toThrow('orcad_migration_dormant_scrollback_snapshots_too_many')
|
||||
expect(() =>
|
||||
parseOrcadMigrationTerminalScrollbackSnapshots([
|
||||
{ ...descriptor(1), byteLength: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1 }
|
||||
])
|
||||
).toThrow('orcad_migration_dormant_scrollback_size_invalid')
|
||||
expect(() =>
|
||||
parseOrcadMigrationTerminalScrollbackSnapshots(
|
||||
Array.from({ length: 52 }, (_, index) => ({
|
||||
...descriptor(index + 1),
|
||||
byteLength: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT
|
||||
}))
|
||||
)
|
||||
).toThrow('orcad_migration_dormant_scrollback_snapshots_too_large')
|
||||
})
|
||||
|
||||
it('requires one exact descriptor for every published session ref', () => {
|
||||
const session = getDefaultWorkspaceSession()
|
||||
const first = descriptor(1)
|
||||
|
||||
@@ -6,11 +6,6 @@ const CANONICAL = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
|
||||
const REMINTED = '$$MFRGGZDFMY:L$$'
|
||||
|
||||
describe('opaque reminted pane keys', () => {
|
||||
it('accepts the exact $$<base32>:L$$ remint form', () => {
|
||||
expect(isOpaqueRemintedPaneKey(REMINTED)).toBe(true)
|
||||
expect(canRegisterPaneKeyAlias(REMINTED, CANONICAL)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects canonical keys, numeric keys with a different tab, and unmatched tokens', () => {
|
||||
expect(isOpaqueRemintedPaneKey(CANONICAL)).toBe(false)
|
||||
expect(canRegisterPaneKeyAlias(CANONICAL, CANONICAL)).toBe(false)
|
||||
@@ -21,9 +16,4 @@ describe('opaque reminted pane keys', () => {
|
||||
expect(canRegisterPaneKeyAlias(REMINTED, '$$ONXW2ZJAON:L$$')).toBe(false)
|
||||
expect(canRegisterPaneKeyAlias(REMINTED, 'tab-1:0')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects oversized canonical destinations', () => {
|
||||
const oversizedPane = `${'x'.repeat(180)}:11111111-1111-4111-8111-111111111111`
|
||||
expect(canRegisterPaneKeyAlias(REMINTED, oversizedPane)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
checkPluginLanguagePackForAuthoring,
|
||||
parsePluginLanguagePackArtifact,
|
||||
PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH,
|
||||
PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES,
|
||||
validatePluginLanguagePackCatalog,
|
||||
pluginLanguageResourceId
|
||||
} from './plugin-language-pack-artifact'
|
||||
import { englishSourceLookup } from '../languages/plugin-translation-string-policy'
|
||||
@@ -21,24 +17,6 @@ describe('plugin language-pack artifacts', () => {
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
it('accepts nested string catalogs and reports their bounded entry count', () => {
|
||||
expect(
|
||||
parsePluginLanguagePackArtifact(
|
||||
JSON.stringify({
|
||||
settings: { appearance: { title: 'Aparência' } },
|
||||
common: { save: 'Salvar' }
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
ok: true,
|
||||
catalog: {
|
||||
settings: { appearance: { title: 'Aparência' } },
|
||||
common: { save: 'Salvar' }
|
||||
},
|
||||
entries: 5
|
||||
})
|
||||
})
|
||||
|
||||
it('drops protected copy instead of rejecting the pack when loading tolerantly', () => {
|
||||
const raw = JSON.stringify({
|
||||
common: { save: 'Sávé' },
|
||||
@@ -52,40 +30,6 @@ describe('plugin language-pack artifacts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('counts protected copy for authors instead of rejecting, still rejecting other problems', () => {
|
||||
expect(
|
||||
checkPluginLanguagePackForAuthoring(
|
||||
JSON.stringify({
|
||||
common: { save: 'Sávé' },
|
||||
components: { 'native-chat': { approval: { allow: 'Álló', deny: 'Déný' } } },
|
||||
browser: { loadFailure: { proceedUnsafe: 'Continuar' } }
|
||||
})
|
||||
)
|
||||
).toEqual({ ok: true, ignoredProtectedStrings: 3 })
|
||||
expect(
|
||||
checkPluginLanguagePackForAuthoring(
|
||||
JSON.stringify({
|
||||
common: { save: 'a\u202esave' },
|
||||
components: { 'native-chat': { approval: { allow: 'Álló' } } }
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: false })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PluginsSettingsSection', 'title', 'Плагины'],
|
||||
['PluginMarketplaceBrowser', 'refresh', 'Обновить'],
|
||||
['PluginDevelopmentSection', 'title', 'Разработка']
|
||||
])('lets a language pack translate %s.%s, which asserts nothing', (component, key, value) => {
|
||||
expect(
|
||||
parsePluginLanguagePackArtifact(
|
||||
JSON.stringify({
|
||||
auto: { components: { settings: { [component]: { [key]: value } } } }
|
||||
})
|
||||
).ok
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// Chrome exemptions must never include claims about plugin permissions; those are the protected prefix’s purpose.
|
||||
it.each([
|
||||
['description', 'Plugins are sandboxed and cannot read your files.'],
|
||||
@@ -100,36 +44,6 @@ describe('plugin language-pack artifacts', () => {
|
||||
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
||||
})
|
||||
|
||||
it('still refuses the development-plugin permission promise', () => {
|
||||
expect(
|
||||
parsePluginLanguagePackArtifact(
|
||||
JSON.stringify({
|
||||
auto: {
|
||||
components: {
|
||||
settings: {
|
||||
PluginDevelopmentSection: { help: 'Dev plugins skip permission review.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
||||
})
|
||||
|
||||
it('still refuses install failure copy that reports a trust event', () => {
|
||||
expect(
|
||||
parsePluginLanguagePackArtifact(
|
||||
JSON.stringify({
|
||||
auto: {
|
||||
components: {
|
||||
settings: { PluginMarketplaceBrowser: { installFailed: 'Installed successfully.' } }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'the Official badge',
|
||||
@@ -211,17 +125,6 @@ describe('plugin language-pack artifacts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('still translates neighbours of protected components', () => {
|
||||
expect(
|
||||
parsePluginLanguagePackArtifact(
|
||||
JSON.stringify({
|
||||
auto: { components: { pluginCatalog: { PluginListRow: { install: 'Instalar' } } } },
|
||||
components: { 'native-chat': { composer: { send: 'Enviar' } } }
|
||||
})
|
||||
).ok
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['array leaf', { settings: { choices: ['one'] } }],
|
||||
['numeric leaf', { settings: { count: 1 } }],
|
||||
@@ -231,36 +134,6 @@ describe('plugin language-pack artifacts', () => {
|
||||
expect(parsePluginLanguagePackArtifact(JSON.stringify(catalog)).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects excessive nesting without recursive validation', () => {
|
||||
let catalog: Record<string, unknown> = { value: 'deep' }
|
||||
for (let index = 0; index <= PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH; index += 1) {
|
||||
catalog = { nested: catalog }
|
||||
}
|
||||
expect(parsePluginLanguagePackArtifact(JSON.stringify(catalog))).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining('depth')
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the entry limit and rejects one additional entry', () => {
|
||||
const catalog = Object.fromEntries(
|
||||
Array.from({ length: PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES }, (_, index) => [
|
||||
`key${index}`,
|
||||
'value'
|
||||
])
|
||||
)
|
||||
|
||||
expect(validatePluginLanguagePackCatalog(catalog)).toMatchObject({
|
||||
ok: true,
|
||||
entries: PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES
|
||||
})
|
||||
catalog.overflow = 'value'
|
||||
expect(validatePluginLanguagePackCatalog(catalog)).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining(`${PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES} entries`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('string limits', () => {
|
||||
const english = englishSourceLookup({
|
||||
common: { retry: 'Retry', detail: 'Line one\nLine two' },
|
||||
@@ -269,21 +142,6 @@ describe('plugin language-pack artifacts', () => {
|
||||
const parse = (catalog: unknown, strictness?: 'strict' | 'tolerant') =>
|
||||
parsePluginLanguagePackArtifact(JSON.stringify(catalog), strictness, english)
|
||||
|
||||
it('caps a translation at 4x its English text with a 64-character floor', () => {
|
||||
expect(parse({ common: { retry: 'x'.repeat(64) } }).ok).toBe(true)
|
||||
expect(parse({ common: { retry: 'x'.repeat(65) } })).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining('common.retry exceeds 64 characters')
|
||||
})
|
||||
const detail = 'x'.repeat(4 * 'Line one\nLine two'.length + 1)
|
||||
expect(parse({ common: { detail } }).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('measures plural forms English lacks against its _other form', () => {
|
||||
expect(parse({ items: { count_few: 'x'.repeat(64) } }).ok).toBe(true)
|
||||
expect(parse({ items: { count_few: 'x'.repeat(65) } }).ok).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a right-to-left override', 'Delete\u202Eetelpmoc'],
|
||||
['an isolate', 'Retry\u2066x\u2069'],
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { legacyPluginCatalog } from '../../../../config/scripts/plugin-authoring/content-validation/export-legacy-plugin-catalog.mjs'
|
||||
import {
|
||||
PLUGIN_CATALOG_RELEASE_LIMIT,
|
||||
pluginCatalogSchema,
|
||||
pluginCatalogVersionError,
|
||||
selectPluginCatalogRelease
|
||||
} from './plugin-catalog'
|
||||
import { pluginCatalogSchema, selectPluginCatalogRelease } from './plugin-catalog'
|
||||
import { pluginCatalogReleaseSchema } from './plugin-catalog-release'
|
||||
import {
|
||||
PLUGIN_MARKETPLACE_ENTRY_LIMIT,
|
||||
pluginMarketplaceListingSchema,
|
||||
pluginMarketplaceSchema
|
||||
} from './plugin-marketplace'
|
||||
import { pluginMarketplaceSchema } from './plugin-marketplace'
|
||||
|
||||
function release(version: string, minimumOrca = '1.4.0') {
|
||||
return pluginCatalogReleaseSchema.parse({
|
||||
@@ -51,70 +42,6 @@ describe('plugin catalog', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps what it can read and skips the rest', () => {
|
||||
const parsed = pluginCatalogSchema.parse(
|
||||
catalog(
|
||||
[
|
||||
entry('acme.one', { surprise: true, description: 'x'.repeat(5_000) }),
|
||||
entry('acme.one', { categories: ['other'] }),
|
||||
entry('acme.two', { releases: [{ ...release('2.0.0'), types: ['widget'] }] }),
|
||||
entry('acme.three', {
|
||||
categories: [
|
||||
'themes',
|
||||
'themes',
|
||||
'Not A Slug',
|
||||
...Array.from({ length: 30 }, (_, i) => `c${i}`)
|
||||
],
|
||||
releases: [{ broken: true }, release('1.1.0')]
|
||||
}),
|
||||
{ id: 5 }
|
||||
],
|
||||
{ future: { anything: true } }
|
||||
)
|
||||
)
|
||||
expect(parsed.plugins.map((plugin) => plugin.id)).toEqual(['acme.one', 'acme.three'])
|
||||
expect(parsed.plugins[0]).not.toHaveProperty('surprise')
|
||||
expect(parsed.plugins[0]?.description).toBeUndefined()
|
||||
expect(parsed.plugins[0]?.categories).toEqual(['themes'])
|
||||
expect(parsed.plugins[1]?.categories).toHaveLength(16)
|
||||
expect(parsed.plugins[1]?.categories.slice(0, 2)).toEqual(['themes', 'c0'])
|
||||
expect(parsed.plugins[1]?.releases.map((r) => r.version)).toEqual(['1.1.0'])
|
||||
})
|
||||
|
||||
it('reads the owner id without carrying it into the cached listing', () => {
|
||||
const parsed = pluginCatalogSchema.parse(
|
||||
catalog([entry('acme.one', { ownerId: 1_234 }), entry('acme.two', { ownerId: 'acme' })])
|
||||
)
|
||||
expect(parsed.plugins.map((plugin) => plugin.id)).toEqual(['acme.one', 'acme.two'])
|
||||
expect(parsed.plugins[0]).not.toHaveProperty('ownerId')
|
||||
// Why strict: older Orca reads the cached listing back with this shape after a downgrade.
|
||||
const { name, owner, plugins } = parsed
|
||||
expect(pluginMarketplaceListingSchema.safeParse({ name, owner, plugins }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('trims an oversized catalog and release list instead of refusing it', () => {
|
||||
const many = Array.from({ length: PLUGIN_MARKETPLACE_ENTRY_LIMIT + 5 }, (_, index) =>
|
||||
entry(`acme.p${index}`, {
|
||||
releases: Array.from({ length: PLUGIN_CATALOG_RELEASE_LIMIT + 3 }, (_, r) =>
|
||||
release(`1.0.${r}`)
|
||||
)
|
||||
})
|
||||
)
|
||||
const parsed = pluginCatalogSchema.parse(catalog(many))
|
||||
expect(parsed.plugins).toHaveLength(PLUGIN_MARKETPLACE_ENTRY_LIMIT)
|
||||
expect(parsed.plugins[0]?.releases).toHaveLength(PLUGIN_CATALOG_RELEASE_LIMIT)
|
||||
})
|
||||
|
||||
it('refuses a newer catalog format with the fix, and requires the version', () => {
|
||||
expect(pluginCatalogVersionError(catalog([], { catalogVersion: 3 }))).toBe(
|
||||
'uses catalog format 3; update Orca to browse this catalog'
|
||||
)
|
||||
expect(pluginCatalogVersionError(catalog([]))).toBeNull()
|
||||
expect(
|
||||
pluginCatalogSchema.safeParse({ name: 'Acme', owner: 'acme', plugins: [] }).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('offers the newest release this Orca can install', () => {
|
||||
const releases = [
|
||||
release('3.0.0', '2.0.0'),
|
||||
@@ -131,20 +58,6 @@ describe('plugin catalog', () => {
|
||||
).toBe('8.0.0')
|
||||
})
|
||||
|
||||
it('exports a frozen v1 file that old clients accept', () => {
|
||||
const parsed = pluginCatalogSchema.parse(
|
||||
catalog([entry('acme.one', { description: 'One theme.' }), entry('acme.two')])
|
||||
)
|
||||
const legacy = legacyPluginCatalog({ ...parsed, plugins: parsed.plugins })
|
||||
expect(pluginMarketplaceSchema.safeParse(legacy).success).toBe(true)
|
||||
expect(legacy.plugins[0]).toEqual({
|
||||
id: 'acme.one',
|
||||
source: { kind: 'git', url: 'https://github.com/acme/acme.one.git', ref: 'main' },
|
||||
description: 'One theme.',
|
||||
categories: ['themes']
|
||||
})
|
||||
})
|
||||
|
||||
it('lists plugins from directories of one repository and refuses unsafe directories', () => {
|
||||
const at = (path: string, index: number) =>
|
||||
entry(`acme.p${index}`, {
|
||||
|
||||
@@ -4,43 +4,6 @@ import { classifyPluginGitAccessFailure } from './plugin-git-access-failure'
|
||||
const HOST = 'the Orca host (build-box)'
|
||||
|
||||
describe('classifyPluginGitAccessFailure', () => {
|
||||
it('explains a private GitHub repository over HTTPS with gh on the named host', () => {
|
||||
const failure = classifyPluginGitAccessFailure({
|
||||
error:
|
||||
"Cloning into 'repository'...\nremote: Repository not found.\nfatal: repository 'https://github.com/acme/private-notes/' not found",
|
||||
url: 'https://github.com/acme/private-notes',
|
||||
executionHost: HOST
|
||||
})
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
transport: 'https',
|
||||
gitHost: 'github.com',
|
||||
executionHost: HOST
|
||||
})
|
||||
expect(failure?.nextSteps[0]).toBe(
|
||||
`On ${HOST}, sign Git in to GitHub without a prompt: \`gh auth login\` then \`gh auth setup-git\` (or any Git credential helper).`
|
||||
)
|
||||
expect(failure?.nextSteps[1]).toContain('git@github.com:acme/private-notes.git')
|
||||
})
|
||||
|
||||
it('recognises a disabled terminal prompt and an SSH key refusal', () => {
|
||||
expect(
|
||||
classifyPluginGitAccessFailure({
|
||||
error: "fatal: could not read Username for 'https://gitlab.com': terminal prompts disabled",
|
||||
url: 'https://gitlab.com/acme/tools/notes',
|
||||
executionHost: HOST
|
||||
})?.nextSteps[0]
|
||||
).toContain('glab auth login')
|
||||
const ssh = classifyPluginGitAccessFailure({
|
||||
error:
|
||||
'git@example.com: Permission denied (publickey).\nfatal: Could not read from remote repository.',
|
||||
url: 'git@example.com:acme/notes.git',
|
||||
executionHost: HOST
|
||||
})
|
||||
expect(ssh?.transport).toBe('ssh')
|
||||
expect(ssh?.nextSteps[0]).toContain('ssh-agent')
|
||||
})
|
||||
|
||||
it('leaves other failures alone and strips credentials from the detail', () => {
|
||||
expect(
|
||||
classifyPluginGitAccessFailure({
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pluginNameClashKeys } from './plugin-name-clashes'
|
||||
|
||||
const item = (key: string, name: string, official = false) => ({
|
||||
key,
|
||||
name,
|
||||
publisher: key.split('.')[0],
|
||||
official
|
||||
})
|
||||
|
||||
describe('pluginNameClashKeys', () => {
|
||||
it('marks only the non-official side of a clash', () => {
|
||||
expect(
|
||||
pluginNameClashKeys([
|
||||
item('stablyai.orca-catppuccin-theme', 'Catppuccin for Orca', true),
|
||||
item('vale-c.catppuccin', 'Catppuccin for Orca')
|
||||
])
|
||||
).toEqual(new Set(['vale-c.catppuccin']))
|
||||
})
|
||||
|
||||
it('marks every side when none is official, ignoring case and spacing', () => {
|
||||
expect(
|
||||
pluginNameClashKeys([
|
||||
item('acme.mocha', 'Mocha'),
|
||||
item('beta.mocha', ' mocha '),
|
||||
item('c.x', 'X')
|
||||
])
|
||||
).toEqual(new Set(['acme.mocha', 'beta.mocha']))
|
||||
})
|
||||
|
||||
it('leaves unique names and official clashes plain', () => {
|
||||
expect(
|
||||
pluginNameClashKeys([
|
||||
item('stablyai.a', 'Same', true),
|
||||
item('stablyai.b', 'Same', true),
|
||||
item('acme.c', 'Other')
|
||||
]).size
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('does not count one item listed twice as a clash', () => {
|
||||
expect(pluginNameClashKeys([item('acme.x', 'X'), item('acme.x', 'X')]).size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -2,16 +2,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import { createTerminalAgentStatusProcessor } from './terminal-agent-status-processor'
|
||||
|
||||
describe('createTerminalAgentStatusProcessor', () => {
|
||||
it('appends OSC 7501 projections, marks them, and leaves the sequence in the stream', () => {
|
||||
const process = createTerminalAgentStatusProcessor()
|
||||
const data = 'x\x1b]7501;state=working:app=codex\x1b\\y'
|
||||
const result = process(data)
|
||||
expect(result.cleanData).toBe(data)
|
||||
expect(result.payloads).toMatchObject([{ state: 'working', agentType: 'codex' }])
|
||||
expect(result.programStatusPayloads?.has(result.payloads[0])).toBe(true)
|
||||
expect(result.lastPayloadCleanOffset).toBe(data.length - 1)
|
||||
})
|
||||
|
||||
it('merges OSC 9999 and OSC 7501 in one chunk, leaving titles untouched', () => {
|
||||
const process = createTerminalAgentStatusProcessor()
|
||||
const report = '\x1b]7501;state=working:app=claude-code\x1b\\'
|
||||
@@ -37,14 +27,6 @@ describe('createTerminalAgentStatusProcessor', () => {
|
||||
expect(activity(osc('state=clear'))).toEqual([null])
|
||||
})
|
||||
|
||||
it('places each activity change at the end of its report in the clean output', () => {
|
||||
const process = createTerminalAgentStatusProcessor()
|
||||
const report = '\x1b]7501;state=working:app=x\x1b\\'
|
||||
expect(process(`ab${report}cd`).programActivity).toEqual([
|
||||
{ offset: 2 + report.length, activity: 'working' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps OSC 9999 and OSC 7501 payloads in byte order', () => {
|
||||
const process = createTerminalAgentStatusProcessor()
|
||||
const osc9999Report = (state: string) => `\x1b]9999;{"state":"${state}"}\x07`
|
||||
|
||||
@@ -148,42 +148,3 @@ it('keeps the existing held/failure/expired-to-held request protocol without ter
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it.each([10, 100])(
|
||||
'terminal completion for %i independent leases delivers once per owner with no followup requests',
|
||||
async (n) => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const onLost = vi.fn()
|
||||
const request = vi.fn(
|
||||
async (
|
||||
params: AgentSessionQueuedMessageEditHoldParams
|
||||
): Promise<AgentSessionQueuedMessageEditHoldResult> =>
|
||||
params.action === 'release' ? { status: 'released' } : { status: 'gone' }
|
||||
)
|
||||
const leases = Array.from({ length: n }, (_, index) =>
|
||||
startQueuedEditLease({
|
||||
sessionId: `session-${index}`,
|
||||
messageId: 'card',
|
||||
editId: `edit-${index}`,
|
||||
expectedBodyFingerprint: `base-${index}`,
|
||||
request,
|
||||
onLost
|
||||
})
|
||||
)
|
||||
await Promise.all(leases.map((lease) => lease.acquired))
|
||||
expect(onLost).toHaveBeenCalledTimes(n)
|
||||
leases.forEach((lease, index) =>
|
||||
expect(onLost.mock.calls[index]?.[0]).toEqual({
|
||||
lease,
|
||||
expected: `base-${index}`,
|
||||
status: 'gone'
|
||||
})
|
||||
)
|
||||
expect(request).toHaveBeenCalledTimes(n)
|
||||
leases.forEach((lease) => lease.end())
|
||||
await vi.advanceTimersByTimeAsync(4 * QUEUED_EDIT_LEASE_RENEW_MS)
|
||||
expect(onLost).toHaveBeenCalledTimes(n)
|
||||
expect(request).toHaveBeenCalledTimes(2 * n)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,52 +8,6 @@ import {
|
||||
|
||||
afterEach(closeSharedControlTestServers)
|
||||
|
||||
it('reports a silent socket as a keepalive timeout, with its uptime', async () => {
|
||||
const server = await createServer({ disableAutoPong: true })
|
||||
const closes: SharedControlSocketCloseRecord[] = []
|
||||
const connection = new RemoteRuntimeSharedControlConnection(server.pairing, {
|
||||
liveness: { pingIntervalMs: 50, livenessTimeoutMs: 200 },
|
||||
onSocketClosed: (record) => closes.push(record)
|
||||
})
|
||||
|
||||
await connection.request('worktree.ps', undefined, 1000)
|
||||
await vi.waitFor(() => expect(closes.length).toBeGreaterThan(0), { timeout: 5000 })
|
||||
|
||||
expect(closes[0]).toMatchObject({
|
||||
cause: 'keepalive_timeout',
|
||||
closeCode: null,
|
||||
state: 'ready',
|
||||
lastRttMs: null
|
||||
})
|
||||
expect(closes[0]?.socketUptimeMs).toBeGreaterThanOrEqual(200)
|
||||
expect(closes[0]?.readyUptimeMs).toBeGreaterThanOrEqual(0)
|
||||
connection.close()
|
||||
})
|
||||
|
||||
it('measures keepalive round trips and reports a server close with its code', async () => {
|
||||
const server = await createServer()
|
||||
const closes: SharedControlSocketCloseRecord[] = []
|
||||
const publishedCauses: unknown[] = []
|
||||
const connection: RemoteRuntimeSharedControlConnection = new RemoteRuntimeSharedControlConnection(
|
||||
server.pairing,
|
||||
{
|
||||
liveness: { pingIntervalMs: 50, livenessTimeoutMs: 1000 },
|
||||
onSocketClosed: (record) => closes.push(record),
|
||||
onDiagnosticsChanged: (diagnostics) => publishedCauses.push(diagnostics.lastCloseCause)
|
||||
}
|
||||
)
|
||||
|
||||
await connection.request('worktree.ps', undefined, 1000)
|
||||
await vi.waitFor(() => expect(connection.getDiagnostics().lastRttMs).toBeGreaterThanOrEqual(0))
|
||||
server.closeClients()
|
||||
await vi.waitFor(() => expect(closes.length).toBeGreaterThan(0))
|
||||
|
||||
expect(closes[0]).toMatchObject({ cause: 'socket_close', closeCode: 4001 })
|
||||
expect(closes[0]?.lastRttMs).toBeGreaterThanOrEqual(0)
|
||||
expect(publishedCauses).toContain('socket_close')
|
||||
connection.close()
|
||||
})
|
||||
|
||||
it('reports an unexpected binary frame as a protocol error, not a transport failure', async () => {
|
||||
const server = await createServer({ sendBinaryAfterAuth: true })
|
||||
const closes: SharedControlSocketCloseRecord[] = []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
encodeMobileE2EEV2Transcript,
|
||||
validateMobileE2EEV2Handshake
|
||||
@@ -7,11 +7,6 @@ import {
|
||||
import { createMobileE2EEV2Fixture, MOBILE_E2EE_V2_VECTOR } from './mobile-e2ee-v2-fixtures'
|
||||
|
||||
describe('mobile E2EE v2 contract', () => {
|
||||
it('validates the exact relay handshake and canonical encodings', () => {
|
||||
const { hello, ready } = createMobileE2EEV2Fixture()
|
||||
expect(validateMobileE2EEV2Handshake(hello, ready)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects unknown fields, noncanonical base64, and an unechoed nonce', () => {
|
||||
const { hello, ready } = createMobileE2EEV2Fixture()
|
||||
expect(validateMobileE2EEV2Handshake({ ...hello, extra: true }, ready)).toBeNull()
|
||||
@@ -29,30 +24,6 @@ describe('mobile E2EE v2 contract', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects each oversized handshake field without decoding it', () => {
|
||||
const { hello, ready } = createMobileE2EEV2Fixture()
|
||||
const oversized = 'A'.repeat(48)
|
||||
const decode = vi.spyOn(globalThis, 'atob')
|
||||
try {
|
||||
expect(validateMobileE2EEV2Handshake(hello, ready)).not.toBeNull()
|
||||
for (const [invalidHello, invalidReady] of [
|
||||
[{ ...hello, clientPublicKeyB64: oversized }, ready],
|
||||
[hello, { ...ready, desktopPublicKeyB64: oversized }],
|
||||
[
|
||||
{ ...hello, clientNonceB64: oversized },
|
||||
{ ...ready, clientNonceB64: oversized }
|
||||
],
|
||||
[hello, { ...ready, desktopNonceB64: oversized }]
|
||||
]) {
|
||||
decode.mockClear()
|
||||
expect(validateMobileE2EEV2Handshake(invalidHello, invalidReady)).toBeNull()
|
||||
expect(decode).not.toHaveBeenCalledWith(oversized)
|
||||
}
|
||||
} finally {
|
||||
decode.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects context and capability-selection changes', () => {
|
||||
const { hello, ready } = createMobileE2EEV2Fixture()
|
||||
expect(
|
||||
|
||||
@@ -58,31 +58,4 @@ describe('mobile E2EE v2 framing', () => {
|
||||
expect(attempt({ payloadKind: 'text' })).toBeNull()
|
||||
expect(attempt({ sessionId: new Uint8Array(32).fill(9) })).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips text and binary frames with successive counters', () => {
|
||||
const kinds = ['text', 'binary', 'text'] as const
|
||||
const frames = kinds.map((payloadKind, counter) =>
|
||||
sealMobileE2EEV2Frame({
|
||||
payload: new Uint8Array([counter]),
|
||||
key,
|
||||
sessionId,
|
||||
direction: 'desktop-to-mobile',
|
||||
payloadKind,
|
||||
counter: BigInt(counter)
|
||||
})
|
||||
)
|
||||
|
||||
for (let counter = 0; counter < frames.length; counter++) {
|
||||
expect(
|
||||
openMobileE2EEV2Frame({
|
||||
frame: frames[counter]!,
|
||||
key,
|
||||
sessionId,
|
||||
direction: 'desktop-to-mobile',
|
||||
payloadKind: kinds[counter]!,
|
||||
expectedCounter: BigInt(counter)
|
||||
})
|
||||
).toEqual(new Uint8Array([counter]))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user