Files
orca/src/cli/handlers/linear.test.ts
T
NeilandOrca 6677b5f171 perf(cli): construct the runtime client only when a command needs it (#10919)
src/cli/index.ts was the only eager value-import of RuntimeClient, and five
other eager modules imported just RuntimeClientError / RuntimeRpcFailureError
from the runtime-client barrel -- dragging in client -> pairing -> zod -> ws
-> e2ee on every invocation. Those error classes live in runtime/types.ts,
which has zero children, so the five imports now point there and the client
loads through the existing (already lazy by design) ctx.client getter.

Eager modules 199 -> 46, with node_modules dropping 94 -> 0.
`orca --help` 2.04x (59.6 -> 29.2 ms); the same for help, no-args, and both
error paths, which return before constructing a client. Commands that DO
construct one still gain 1.10-1.12x from not eagerly parsing the transport
the local path never uses.

Correction to an earlier note: websocket-transport alone is ~24 modules /
~8 ms, not the 107 / 28 ms once recorded -- that figure wrongly charged it
for zod, which enters through shared/pairing on a different edge. Marginal
cost, never isolated cost.

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:16:01 -07:00

745 lines
19 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
vi.mock('../runtime-client', async () => {
class RuntimeClient {
readonly isRemote: boolean
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
constructor(
_userDataPath?: string,
_requestTimeoutMs?: number,
remotePairingCode = process.env.ORCA_PAIRING_CODE ?? null,
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null
) {
this.isRemote = Boolean(remotePairingCode || environmentSelector)
}
}
// Why: re-export the REAL error classes; format.ts narrows with `instanceof`
// against ./runtime/types, so a look-alike would collapse every CLI error
// code into the generic `runtime_error` shape.
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError
}
})
import { main } from '../index'
import { okFixture, queueFixtures } from '../test-fixtures'
describe('orca linear CLI handlers', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
vi.restoreAllMocks()
callMock.mockReset()
process.env = { ...originalEnv }
// Why: these tests can run inside an Orca-managed terminal, which exports
// real worktree/terminal/pairing env hints; clear them so handler context
// assertions stay deterministic.
delete process.env.ORCA_WORKTREE_ID
delete process.env.ORCA_TERMINAL_HANDLE
delete process.env.ORCA_PAIRING_CODE
delete process.env.ORCA_ENVIRONMENT
process.exitCode = undefined
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('maps --full issue reads to read-only issueContext RPC', async () => {
queueFixtures(callMock, okFixture('req_linear', issueResult()))
await main(['linear', 'issue', 'ENG-123', '--full', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueContext',
{
input: 'ENG-123',
current: false,
workspaceId: undefined,
include: {
comments: true,
children: true,
attachments: true,
relations: true,
activity: true
},
depth: 2,
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 120_000 }
)
})
it('maps MCP-compatible list-issues filters and cursor pagination', async () => {
queueFixtures(callMock, okFixture('req_list', issueResult()))
await main(
[
'linear',
'list-issues',
'--team',
'ENG',
'--assignee',
'me',
'--priority',
'2',
'--cursor',
'next-page',
'--order-by',
'createdAt',
'--include-archived',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.mcpListIssues',
expect.objectContaining({
team: 'ENG',
assignee: 'me',
priority: 2,
cursor: 'next-page',
orderBy: 'createdAt',
includeArchived: true
})
)
})
it('keeps global boolean flags before Linear commands from consuming command tokens', async () => {
queueFixtures(callMock, okFixture('req_linear', issueResult()))
await main(['--json', 'linear', 'issue', 'ENG-123', '--full'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueContext',
expect.objectContaining({
input: 'ENG-123',
include: expect.objectContaining({
comments: true,
children: true,
attachments: true,
relations: true,
activity: true
})
}),
{ timeoutMs: 120_000 }
)
})
it('passes verified current-context hints without resolving cwd for remote runtimes', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_123'
process.env.ORCA_WORKTREE_ID = 'repo::/srv/app'
process.env.ORCA_PAIRING_CODE = 'orca://pair?payload=bad'
queueFixtures(callMock, okFixture('req_linear', issueResult()))
await main(['linear', 'issue', '--current', '--comments', '--json'], '/client/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueContext',
expect.objectContaining({
input: undefined,
current: true,
include: expect.objectContaining({ comments: true }),
context: {
remote: true,
worktreeId: 'repo::/srv/app',
terminalHandle: 'term_123'
}
}),
{ timeoutMs: undefined }
)
})
it('rejects --depth unless children are requested', async () => {
await main(['linear', 'issue', 'ENG-123', '--depth', '3'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'--depth requires --children or --full'
)
expect(process.exitCode).toBe(1)
})
it('maps search to agent search RPC with capped limit', async () => {
queueFixtures(
callMock,
okFixture('req_search', {
issues: [],
meta: { query: 'auth', workspaceId: 'all', limit: 50, returned: 0, limitReached: false }
})
)
await main(['linear', 'search', 'auth', '--workspace', 'all', '--limit', '500'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('linear.agentSearchIssues', {
query: 'auth',
limit: 50,
workspaceId: 'all'
})
})
it('maps project list to agent project RPC with capped limit', async () => {
queueFixtures(
callMock,
okFixture('req_projects', {
projects: [],
meta: {
query: 'launch',
workspaceId: 'all',
limit: 50,
returned: 0,
hasMore: false,
partial: false,
workspaceErrors: []
}
})
)
await main(
['linear', 'project', 'list', '--query', 'launch', '--workspace', 'all', '--limit', '500'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('linear.agentProjectList', {
query: 'launch',
limit: 50,
workspaceId: 'all'
})
})
it('keeps boolean flags between Linear and search from consuming the subcommand', async () => {
queueFixtures(
callMock,
okFixture('req_search', {
issues: [],
meta: { query: 'auth', workspaceId: undefined, limit: 1, returned: 0, limitReached: false }
})
)
await main(['linear', '--json', 'search', 'auth', '--limit', '1'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('linear.agentSearchIssues', {
query: 'auth',
limit: 1,
workspaceId: undefined
})
})
it('maps status writes to the agent write RPC with an explicit target', async () => {
queueFixtures(callMock, okFixture('req_status', statusSetResult()))
await main(['linear', 'status', 'set', 'ENG-123', '--to', 'In Review', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueSetState',
{
input: 'ENG-123',
current: false,
workspaceId: undefined,
to: 'In Review',
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 75_000 }
)
})
it('maps priority writes with Linear API priority numbering', async () => {
queueFixtures(callMock, okFixture('req_priority', taskUpdateResult('priority')))
await main(['linear', 'priority', 'set', 'ENG-123', '--to', 'urgent', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueUpdateTask',
expect.objectContaining({
input: 'ENG-123',
operation: 'priority',
priority: 1
}),
{ timeoutMs: 75_000 }
)
})
it('maps relation writes to the current-issue perspective', async () => {
queueFixtures(callMock, okFixture('req_relation', relationWriteResult()))
await main(
[
'linear',
'relation',
'add',
'ENG-123',
'--related',
'ENG-456',
'--type',
'blocked-by',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.issueRelationWrite',
{
input: 'ENG-123',
current: false,
workspaceId: undefined,
relatedInput: 'ENG-456',
relationship: 'blockedBy',
operation: 'add',
context: { remote: false, cwd: '/tmp/repo' }
},
{ timeoutMs: 75_000 }
)
})
it('dispatches relation rm through the remove handler', async () => {
queueFixtures(callMock, okFixture('req_relation_rm', relationWriteResult()))
await main(
[
'linear',
'relation',
'rm',
'ENG-123',
'--related',
'ENG-456',
'--type',
'related',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.issueRelationWrite',
expect.objectContaining({
input: 'ENG-123',
relatedInput: 'ENG-456',
relationship: 'relatedTo',
operation: 'remove'
}),
{ timeoutMs: 75_000 }
)
})
it('rejects impossible due dates before dispatch', async () => {
await main(['linear', 'due-date', 'set', 'ENG-123', '--to', '2026-02-30'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain('real calendar date')
})
it('preserves repeated labels for label updates', async () => {
queueFixtures(callMock, okFixture('req_label', taskUpdateResult('labels')))
await main(
['linear', 'label', 'add', 'ENG-123', '--label', 'Bug', '--label', 'Regression', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.issueUpdateTask',
expect.objectContaining({
input: 'ENG-123',
operation: 'labels',
labelMode: 'add',
labels: ['Bug', 'Regression']
}),
{ timeoutMs: 75_000 }
)
})
it('requires exact write targets for issue writes', async () => {
await main(['linear', 'comment', 'add', '--body', 'done'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'Pass a Linear issue id or --current'
)
expect(process.exitCode).toBe(1)
})
it('rejects --workspace all for writes before dispatch', async () => {
await main(
[
'linear',
'attach',
'ENG-123',
'--url',
'https://example.com/review/123',
'--workspace',
'all'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'--workspace all is not valid for Linear writes'
)
expect(process.exitCode).toBe(1)
})
it('reads comment bodies from stdin and passes retry write ids through', async () => {
const stdin = mockStdin(false, ['line one\n', 'line two'])
queueFixtures(callMock, okFixture('req_comment', commentAddResult()))
try {
await main(
[
'linear',
'comment',
'add',
'--current',
'--body-file',
'-',
'--reply-to',
'comment-parent',
'--write-id',
'123e4567-e89b-12d3-a456-426614174000',
'--json'
],
'/tmp/repo'
)
} finally {
stdin.restore()
}
expect(callMock).toHaveBeenCalledWith(
'linear.issueAddComment',
{
input: undefined,
current: true,
workspaceId: undefined,
body: 'line one\nline two',
replyTo: 'comment-parent',
writeId: '123e4567-e89b-12d3-a456-426614174000',
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 75_000 }
)
})
it('rejects malformed write ids before dispatch', async () => {
await main(
[
'linear',
'attach',
'ENG-123',
'--url',
'https://example.com/review/123',
'--write-id',
'not-a-uuid',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
const payload = JSON.parse(String(vi.mocked(console.log).mock.calls[0][0])) as {
error: { code: string }
}
expect(payload.error.code).toBe('linear_invalid_write_id')
})
it('maps create with parent-current and optional body flags', async () => {
queueFixtures(callMock, okFixture('req_create', createResult()))
await main(
[
'linear',
'create',
'--title',
'Follow-up bug',
'--body',
'Concrete repro',
'--parent-current',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.issueCreate',
{
title: 'Follow-up bug',
body: 'Concrete repro',
teamInput: undefined,
state: undefined,
assignee: undefined,
priority: undefined,
estimate: undefined,
dueDate: undefined,
labels: [],
parentInput: undefined,
parentCurrent: true,
workspaceId: undefined,
writeId: undefined,
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 75_000 }
)
})
it('maps enriched create task fields', async () => {
queueFixtures(callMock, okFixture('req_create', createResult()))
await main(
[
'linear',
'create',
'--title',
'Triage bug',
'--team',
'ENG',
'--project',
'project-1',
'--state',
'Todo',
'--assignee',
'me',
'--priority',
'high',
'--estimate',
'2',
'--due-date',
'2026-06-30',
'--label',
'Bug',
'--label',
'Regression',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.issueCreate',
expect.objectContaining({
title: 'Triage bug',
teamInput: 'ENG',
projectInput: 'project-1',
state: 'Todo',
assignee: 'me',
priority: 2,
estimate: 2,
dueDate: '2026-06-30',
labels: ['Bug', 'Regression']
}),
{ timeoutMs: 75_000 }
)
})
it('maps MCP-style save-issue updates and explicit clears', async () => {
queueFixtures(callMock, okFixture('req_save', createResult()))
await main(
[
'linear',
'save-issue',
'ENG-123',
'--title',
'Updated title',
'--assignee',
'null',
'--estimate',
'null',
'--due-date',
'null',
'--project',
'null',
'--label',
'Bug',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.saveIssue',
expect.objectContaining({
input: 'ENG-123',
title: 'Updated title',
assignee: null,
estimate: null,
dueDate: null,
project: null,
labels: ['Bug']
}),
{ timeoutMs: 75_000 }
)
})
it('rejects duplicate body inputs before dispatch', async () => {
await main(
['linear', 'create', '--title', 'Bug', '--body', 'one', '--body-file', 'body.md'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain('Use either --body or --body-file')
})
})
function issueResult(): unknown {
return {
issue: {
id: 'issue-id',
identifier: 'ENG-123',
title: 'Fix auth',
url: 'https://linear.app/acme/issue/ENG-123',
state: { name: 'Todo' },
team: { name: 'Engineering' },
labels: []
},
meta: {
requested: {
current: false,
include: {
comments: false,
children: false,
attachments: false,
relations: false,
activity: false
},
depth: 2
},
resolved: {
id: 'issue-id',
identifier: 'ENG-123',
workspaceId: 'workspace-1',
workspaceName: 'Acme'
},
partial: false,
includeErrors: [],
sections: {}
}
}
}
function statusSetResult(): unknown {
return {
issue: { id: 'issue-id', identifier: 'ENG-123', url: 'https://linear.app/acme/issue/ENG-123' },
state: { id: 'state-review', name: 'In Review', type: 'started' },
previousState: { id: 'state-started', name: 'In Progress' },
meta: { workspaceId: 'workspace-1', alreadyInState: false }
}
}
function commentAddResult(): unknown {
return {
comment: { id: 'comment-id', url: null, parentId: 'comment-parent' },
issue: { id: 'issue-id', identifier: 'ENG-123', url: 'https://linear.app/acme/issue/ENG-123' },
meta: {
workspaceId: 'workspace-1',
bodyChars: 17,
writeId: '123e4567-e89b-12d3-a456-426614174000',
deduplicated: false
}
}
}
function createResult(): unknown {
return {
issue: {
id: 'issue-child',
identifier: 'ENG-456',
title: 'Follow-up bug',
url: 'https://linear.app/acme/issue/ENG-456',
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
state: { id: 'state-triage', name: 'Triage' },
parent: { id: 'issue-id', identifier: 'ENG-123' }
},
meta: {
workspaceId: 'workspace-1',
writeId: '123e4567-e89b-12d3-a456-426614174000',
deduplicated: false
}
}
}
function taskUpdateResult(operation: string): unknown {
return {
issue: { id: 'issue-id', identifier: 'ENG-123', url: 'https://linear.app/acme/issue/ENG-123' },
operation,
previous: {},
current: {},
meta: { workspaceId: 'workspace-1', alreadySet: false }
}
}
function relationWriteResult(): unknown {
return {
issue: {
id: 'issue-id',
identifier: 'ENG-123',
title: 'Current',
url: 'https://linear.app/acme/issue/ENG-123'
},
relatedIssue: {
id: 'related-id',
identifier: 'ENG-456',
title: 'Related',
url: 'https://linear.app/acme/issue/ENG-456'
},
relation: {
id: 'relation-id',
type: 'blocks',
direction: 'inbound',
relationship: 'blockedBy'
},
operation: 'add',
meta: { workspaceId: 'workspace-1', alreadySet: false }
}
}
function mockStdin(isTTY: boolean, chunks: string[]): { restore: () => void } {
const stdin = process.stdin
const previousIsTTY = stdin.isTTY
const previousAsyncIterator = stdin[Symbol.asyncIterator]
Object.defineProperty(stdin, 'isTTY', {
configurable: true,
value: isTTY
})
;(stdin as unknown as Record<symbol, unknown>)[Symbol.asyncIterator] = async function* () {
for (const chunk of chunks) {
yield chunk
}
return undefined
}
return {
restore: () => {
Object.defineProperty(stdin, 'isTTY', {
configurable: true,
value: previousIsTTY
})
if (previousAsyncIterator) {
;(stdin as unknown as Record<symbol, unknown>)[Symbol.asyncIterator] = previousAsyncIterator
} else {
Reflect.deleteProperty(stdin, Symbol.asyncIterator)
}
}
}
}