Files
orca/tests/e2e/linear-cli-recovery.unit.test.ts
T
Brennan Benson 6e2b245ac6 Recover brief Linear read failures and retain network causes (#35074)
* Recover brief Linear read transport failures

* Preserve safe diagnostics across runtime Linear reads

* Match existing attachment metadata in read contract

* Use a checked runtime in Linear CLI integration tests

* Keep Linear CLI integration coverage in the backend test graph

* Use integration test ownership for Linear CLI recovery coverage

* Observe empty Linear recovery traces before the first sink write
2026-10-28 13:51:44 -07:00

358 lines
13 KiB
TypeScript

import '../../src/main/runtime/rpc/unused-default-rpc-methods.test-fixture'
import assert from 'node:assert/strict'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime'
import { RpcDispatcher } from '../../src/main/runtime/rpc/dispatcher'
import { LINEAR_AGENT_ACCESS_METHODS } from '../../src/main/runtime/rpc/methods/linear-agent-access'
import { connect, getClient, testConnection } from '../../src/main/linear/client'
import { updateIssueForAgent } from '../../src/main/linear/linear-issue-mutations'
import { writeIssueRelation } from '../../src/main/linear/issue-relation-write'
import { sendLinearData, serveLinearHttp } from '../../src/main/linear/linear-sdk-http.test-fixture'
import { LinearAgentAccessError } from '../../src/main/linear/issue-context/errors'
import { RuntimeRpcFailureError } from '../../src/cli/runtime/types'
import { main } from '../../src/cli/index'
const boundary = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/unused-qa-runtime'),
save: vi.fn(),
upsert: vi.fn(),
workspace: {
id: 'workspace-proof',
organizationId: 'workspace-proof',
organizationName: 'Proof',
organizationUrlKey: 'proof',
displayName: 'Proof viewer',
email: 'proof@example.invalid'
}
}))
vi.mock('../../src/cli/runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('../../src/cli/index-test-harness.js')
return createRuntimeClientModuleMock(boundary)
})
vi.mock('../../src/main/linear/linear-token-store', () => ({
loadToken: () => 'local-proof-token',
saveWorkspaceToken: boundary.save,
getWorkspaceTokenProtection: () => null,
clearToken: vi.fn(),
clearTokenFile: vi.fn(),
replaceLegacyWorkspace: vi.fn()
}))
vi.mock('../../src/main/linear/linear-workspace-registry', () => ({
getWorkspaceState: () => ({
workspaces: [boundary.workspace],
activeWorkspaceId: boundary.workspace.id,
selectedWorkspaceId: boundary.workspace.id
}),
resolveWorkspaceId: (id?: string) => id ?? boundary.workspace.id,
getCredentialError: () => undefined,
getLegacyWorkspace: () => null,
getWorkspaceFile: vi.fn(),
writeWorkspaceFile: vi.fn(),
upsertWorkspace: boundary.upsert
}))
const issue = {
id: 'issue-proof',
identifier: 'ENG-1',
title: 'Recovered issue',
url: 'https://linear.app/proof/issue/ENG-1',
team: { id: 'team-proof', key: 'ENG', name: 'Engineering' },
state: { id: 'state-proof', name: 'Todo', type: 'unstarted', color: '' },
parent: null,
assignee: null,
priority: 2,
updatedAt: '2026-10-28T00:00:00.000Z',
labels: { nodes: [] }
}
const relatedIssue = {
id: 'related-proof',
identifier: 'ENG-2',
title: 'Related',
url: 'https://linear.app/proof/issue/ENG-2'
}
const nativeFetch = globalThis.fetch
let runtime: OrcaRuntimeService
let server: Awaited<ReturnType<typeof serveLinearHttp>>
let counts: Map<string, number>
let signals: (AbortSignal | null | undefined)[]
let persistent = false
let dropMutation = false
let previousExitCode: typeof process.exitCode
beforeEach(async () => {
vi.clearAllMocks()
counts = new Map()
signals = []
persistent = false
dropMutation = false
previousExitCode = process.exitCode
for (const key of [
'ORCA_ENVIRONMENT',
'ORCA_PAIRING_CODE',
'ORCA_REMOTE_PAIRING',
'ORCA_TERMINAL_HANDLE',
'ORCA_WORKSPACE_ID',
'ORCA_WORKTREE_ID'
]) {
vi.stubEnv(key, '')
}
server = await serveLinearHttp((receipt, response) => {
const operation = /\b(?:query|mutation)\s+(\w+)/.exec(receipt.query)?.[1] ?? 'unknown'
const count = (counts.get(operation) ?? 0) + 1
counts.set(operation, count)
const mutation = /^\s*mutation\b/.test(receipt.query)
if ((mutation && dropMutation) || (!mutation && (persistent || count === 1))) {
response.destroy()
return
}
const connection = { nodes: [issue], pageInfo: { hasNextPage: false } }
if (operation === 'OrcaAgentLinearIssueRelations') {
sendLinearData(response, {
issue: {
relations: {
nodes: [{ id: 'relation-proof', type: 'blocks', issue, relatedIssue }],
pageInfo: { hasNextPage: false }
}
}
})
} else if (operation === 'OrcaAgentLinearIssue' || operation === 'OrcaLinearIssueByUuid') {
sendLinearData(response, { issue })
} else if (operation === 'OrcaAgentLinearSearch') {
sendLinearData(response, { searchIssues: connection })
} else if (operation === 'OrcaLinearIssues') {
sendLinearData(response, { issues: connection })
} else if (operation === 'OrcaLinearProjects') {
sendLinearData(response, {
projects: {
nodes: [
{
id: 'project-proof',
name: 'Recovered project',
url: 'https://linear.app/proof/project/project-proof'
}
],
pageInfo: { hasNextPage: false }
}
})
} else if (operation === 'viewer') {
sendLinearData(response, {
viewer: {
id: 'viewer-proof',
displayName: boundary.workspace.displayName,
email: boundary.workspace.email
}
})
} else if (operation === 'organization') {
sendLinearData(response, {
organization: {
id: boundary.workspace.id,
name: boundary.workspace.organizationName,
urlKey: boundary.workspace.organizationUrlKey,
projectStatuses: []
}
})
} else if (mutation && receipt.query.includes('issueUpdate')) {
sendLinearData(response, { issueUpdate: { success: true } })
} else {
response.writeHead(400, { 'content-type': 'application/json' })
response.end(JSON.stringify({ errors: [{ message: `Unexpected operation ${operation}` }] }))
}
})
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
if (String(input) !== 'https://api.linear.app/graphql') {
throw new Error('Unexpected SDK endpoint')
}
signals.push(init?.signal)
return nativeFetch(server.apiUrl, init)
})
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
runtime = new OrcaRuntimeService(null)
vi.spyOn(runtime, 'getRuntimeId').mockReturnValue('runtime-proof')
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
boundary.callMock.mockImplementation(async (method: string, params: unknown) => {
const response = await dispatcher.dispatch({
id: 'request-proof',
authToken: 'proof',
method,
params
})
if (!response.ok) {
throw new RuntimeRpcFailureError(response)
}
return response
})
})
afterEach(async () => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
process.exitCode = previousExitCode
await server.close()
})
it('parses and dispatches issue, search, list and project commands through real recovering read handlers', async () => {
for (const args of [
['issue', 'ENG-1'],
['search', '--query', 'recovery'],
['list', '--filter', 'all', '--limit', '1'],
['project', 'list', '--limit', '1']
]) {
await main(['linear', ...args, '--workspace', boundary.workspace.id, '--json'])
expect(process.exitCode).toBe(previousExitCode)
}
const output = vi.mocked(console.log).mock.calls.map(([value]) => JSON.parse(String(value)))
expect(output).toHaveLength(4)
expect(output[0]).toMatchObject({
ok: true,
result: { issue: { identifier: issue.identifier }, meta: { partial: false } }
})
expect(output[1]).toMatchObject({
ok: true,
result: { issues: [{ identifier: issue.identifier }] }
})
expect(output[2]).toMatchObject({
ok: true,
result: { issues: [{ priority: 2, priorityLabel: 'high' }], truncated: false }
})
expect(output[3]).toMatchObject({
ok: true,
result: { projects: [{ id: 'project-proof', name: 'Recovered project' }], truncated: false }
})
expect([...counts.entries()]).toEqual([
['OrcaAgentLinearIssue', 2],
['OrcaAgentLinearSearch', 2],
['OrcaLinearIssues', 2],
['OrcaLinearProjects', 2]
])
expect(server.receipts).toHaveLength(8)
expect(
server.receipts.slice(0, 2).map((receipt) => receipt.headers['public-file-urls-expire-in'])
).toEqual(['3600', '3600'])
expect(
server.receipts.every((receipt) => receipt.headers.authorization === 'local-proof-token')
).toBe(true)
expect(server.receipts[2].variables).toMatchObject({ term: 'recovery' })
expect(boundary.callMock.mock.calls.map(([method]) => method)).toEqual([
'linear.issueContext',
'linear.agentSearchIssues',
'linear.agentIssueList',
'linear.agentProjectList'
])
})
it.each([
['issue', 'ENG-1'],
['project', 'list']
] as const)(
'keeps the existing %s CLI/RPC failure envelope and host-only diagnostics after real resets',
async (command, target) => {
persistent = true
const projectRead = runtime.linearProjectListForAgents.bind(runtime)
let hostFailure: unknown
vi.spyOn(runtime, 'linearProjectListForAgents').mockImplementation(async (params) => {
try {
return await projectRead(params)
} catch (error) {
hostFailure = error
throw error
}
})
await main(['linear', command, target, '--workspace', boundary.workspace.id, '--json'])
const output = [...vi.mocked(console.log).mock.calls, ...vi.mocked(console.error).mock.calls]
.map(([value]) => String(value))
.join('\n')
expect(JSON.parse(output)).toMatchObject({
ok: false,
error: { code: 'linear_network_error', message: 'fetch failed' }
})
const outwardError = { code: 'linear_network_error', message: 'fetch failed' }
expect(JSON.parse(output).error).toEqual(outwardError)
const rpcFailure: unknown = await boundary.callMock.mock.results[0].value.catch(
(error: unknown) => error
)
assert(rpcFailure instanceof RuntimeRpcFailureError)
expect(rpcFailure.response.error).toEqual(outwardError)
expect(JSON.stringify(rpcFailure.response)).not.toMatch(
/UND_ERR_SOCKET|ECONNRESET|local-proof-token|Linear transport failure/
)
if (command === 'project') {
assert(hostFailure instanceof LinearAgentAccessError)
expect(hostFailure).toMatchObject(outwardError)
expect(hostFailure.data).toBeUndefined()
assert(hostFailure.cause instanceof Error)
expect(hostFailure.cause.message).toMatch(
/^Linear transport failure: (UND_ERR_SOCKET|ECONNRESET)$/
)
expect(hostFailure.cause.cause).toBeUndefined()
expect(counts.get('OrcaLinearProjects')).toBe(3)
}
expect(output).not.toMatch(/UND_ERR_SOCKET|ECONNRESET|local-proof-token/)
expect(server.receipts).toHaveLength(3)
expect(process.exitCode).toBe(1)
}
)
it('applies recovery at connect, connection-test and legacy getClient constructor sites', async () => {
const client = getClient(boundary.workspace.id)
assert(client)
await expect(client.viewer).resolves.toMatchObject({ id: 'viewer-proof' })
expect([...counts.values()]).toEqual([2])
for (const invoke of [
() => connect('local-proof-token'),
() => testConnection(boundary.workspace.id)
]) {
counts.clear()
await expect(invoke()).resolves.toMatchObject({ ok: true, workspace: boundary.workspace })
expect([...counts.entries()]).toEqual([
['viewer', 2],
['organization', 2]
])
}
expect(boundary.save).toHaveBeenCalledTimes(2)
})
it('retries only the readback after a successful mutation and recovers signal-bound relation reads', async () => {
const signal = new AbortController().signal
await expect(
updateIssueForAgent(issue.id, { title: issue.title }, boundary.workspace.id, { signal })
).resolves.toMatchObject({ id: issue.id })
expect(server.receipts).toHaveLength(3)
expect(server.receipts.map((receipt) => receipt.query.trimStart().split(' ')[0])).toEqual([
'mutation',
'query',
'query'
])
expect(server.receipts[0].variables).toMatchObject({
id: issue.id,
input: { title: issue.title }
})
expect(signals.every((received) => received === signal)).toBe(true)
counts.clear()
signals = []
await expect(
writeIssueRelation({
issue,
relatedIssue,
operation: 'add',
relationship: 'blocks',
workspaceId: boundary.workspace.id,
signal
})
).resolves.toMatchObject({ meta: { alreadySet: true } })
expect([...counts.entries()]).toEqual([['OrcaAgentLinearIssueRelations', 2]])
expect(signals).toEqual([signal, signal])
counts.clear()
dropMutation = true
const before = server.receipts.length
await expect(
updateIssueForAgent(issue.id, { title: 'Unconfirmed' }, boundary.workspace.id, { signal })
).rejects.toMatchObject({ kind: 'unconfirmed' })
expect(server.receipts).toHaveLength(before + 1)
expect([...counts.values()]).toEqual([1])
})