diff --git a/tests/unit/backends/catalog.test.ts b/tests/unit/backends/catalog.test.ts new file mode 100644 index 00000000..2ec13cc5 --- /dev/null +++ b/tests/unit/backends/catalog.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest'; +import { + CLAUDE_CODE_ENGINE_DEFINITION, + CODEX_ENGINE_DEFINITION, + DEFAULT_ENGINE_CATALOG, + LLMIST_ENGINE_DEFINITION, + OPENCODE_ENGINE_DEFINITION, +} from '../../../src/backends/catalog.js'; +import type { AgentEngineDefinition } from '../../../src/backends/types.js'; + +describe('DEFAULT_ENGINE_CATALOG', () => { + it('contains exactly 4 engines', () => { + expect(DEFAULT_ENGINE_CATALOG).toHaveLength(4); + }); + + it('contains llmist, claude-code, codex, and opencode engines', () => { + const ids = DEFAULT_ENGINE_CATALOG.map((e) => e.id); + expect(ids).toContain('llmist'); + expect(ids).toContain('claude-code'); + expect(ids).toContain('codex'); + expect(ids).toContain('opencode'); + }); + + it('has no duplicate IDs', () => { + const ids = DEFAULT_ENGINE_CATALOG.map((e) => e.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + }); + + it('every engine has all required fields', () => { + for (const engine of DEFAULT_ENGINE_CATALOG) { + expect(typeof engine.id).toBe('string'); + expect(engine.id.length).toBeGreaterThan(0); + expect(typeof engine.label).toBe('string'); + expect(engine.label.length).toBeGreaterThan(0); + expect(typeof engine.description).toBe('string'); + expect(engine.description.length).toBeGreaterThan(0); + expect(Array.isArray(engine.capabilities)).toBe(true); + expect(typeof engine.modelSelection).toBe('object'); + expect(typeof engine.logLabel).toBe('string'); + expect(engine.logLabel.length).toBeGreaterThan(0); + } + }); + + it('is ordered: llmist, claude-code, codex, opencode', () => { + expect(DEFAULT_ENGINE_CATALOG[0].id).toBe('llmist'); + expect(DEFAULT_ENGINE_CATALOG[1].id).toBe('claude-code'); + expect(DEFAULT_ENGINE_CATALOG[2].id).toBe('codex'); + expect(DEFAULT_ENGINE_CATALOG[3].id).toBe('opencode'); + }); +}); + +// ─── Individual engine definitions ──────────────────────────────────────────── +describe('LLMIST_ENGINE_DEFINITION', () => { + it('has correct id and label', () => { + expect(LLMIST_ENGINE_DEFINITION.id).toBe('llmist'); + expect(LLMIST_ENGINE_DEFINITION.label).toBe('LLMist'); + }); + + it('has free-text model selection', () => { + expect(LLMIST_ENGINE_DEFINITION.modelSelection.type).toBe('free-text'); + }); + + it('has expected capabilities', () => { + expect(LLMIST_ENGINE_DEFINITION.capabilities).toContain('synthetic_tool_context'); + expect(LLMIST_ENGINE_DEFINITION.capabilities).toContain('streaming_text_events'); + expect(LLMIST_ENGINE_DEFINITION.capabilities).toContain('scoped_env_secrets'); + }); + + it('does not have settings (llmist has no engine-specific settings)', () => { + expect(LLMIST_ENGINE_DEFINITION.settings).toBeUndefined(); + }); +}); + +describe('CLAUDE_CODE_ENGINE_DEFINITION', () => { + it('has correct id and label', () => { + expect(CLAUDE_CODE_ENGINE_DEFINITION.id).toBe('claude-code'); + expect(CLAUDE_CODE_ENGINE_DEFINITION.label).toBe('Claude Code'); + }); + + it('has select model selection with default label', () => { + expect(CLAUDE_CODE_ENGINE_DEFINITION.modelSelection.type).toBe('select'); + if (CLAUDE_CODE_ENGINE_DEFINITION.modelSelection.type === 'select') { + expect(CLAUDE_CODE_ENGINE_DEFINITION.modelSelection.defaultValueLabel).toContain('Sonnet'); + expect(Array.isArray(CLAUDE_CODE_ENGINE_DEFINITION.modelSelection.options)).toBe(true); + expect(CLAUDE_CODE_ENGINE_DEFINITION.modelSelection.options.length).toBeGreaterThan(0); + } + }); + + it('has native file edit and external CLI capabilities', () => { + expect(CLAUDE_CODE_ENGINE_DEFINITION.capabilities).toContain('native_file_edit_tools'); + expect(CLAUDE_CODE_ENGINE_DEFINITION.capabilities).toContain('external_cli_tools'); + }); + + it('has offloaded context capabilities', () => { + expect(CLAUDE_CODE_ENGINE_DEFINITION.capabilities).toContain('inline_prompt_context'); + expect(CLAUDE_CODE_ENGINE_DEFINITION.capabilities).toContain('offloaded_context_files'); + }); + + it('has settings with title and fields', () => { + expect(CLAUDE_CODE_ENGINE_DEFINITION.settings).toBeDefined(); + expect(CLAUDE_CODE_ENGINE_DEFINITION.settings?.title).toBe('Claude Code Settings'); + expect(Array.isArray(CLAUDE_CODE_ENGINE_DEFINITION.settings?.fields)).toBe(true); + }); + + it('settings fields include effort and thinking', () => { + const fields = CLAUDE_CODE_ENGINE_DEFINITION.settings?.fields ?? []; + const keys = fields.map((f) => f.key); + expect(keys).toContain('effort'); + expect(keys).toContain('thinking'); + }); + + it('effort field has correct options', () => { + const fields = CLAUDE_CODE_ENGINE_DEFINITION.settings?.fields ?? []; + const effortField = fields.find((f) => f.key === 'effort'); + expect(effortField).toBeDefined(); + expect(effortField?.type).toBe('select'); + const options = (effortField as { options?: { value: string }[] })?.options ?? []; + const values = options.map((o) => o.value); + expect(values).toContain('low'); + expect(values).toContain('medium'); + expect(values).toContain('high'); + expect(values).toContain('max'); + }); +}); + +describe('CODEX_ENGINE_DEFINITION', () => { + it('has correct id and label', () => { + expect(CODEX_ENGINE_DEFINITION.id).toBe('codex'); + expect(CODEX_ENGINE_DEFINITION.label).toBe('Codex'); + }); + + it('has select model selection with default label', () => { + expect(CODEX_ENGINE_DEFINITION.modelSelection.type).toBe('select'); + if (CODEX_ENGINE_DEFINITION.modelSelection.type === 'select') { + expect(CODEX_ENGINE_DEFINITION.modelSelection.defaultValueLabel).toBeDefined(); + expect(Array.isArray(CODEX_ENGINE_DEFINITION.modelSelection.options)).toBe(true); + expect(CODEX_ENGINE_DEFINITION.modelSelection.options.length).toBeGreaterThan(0); + } + }); + + it('has native file edit and external CLI capabilities', () => { + expect(CODEX_ENGINE_DEFINITION.capabilities).toContain('native_file_edit_tools'); + expect(CODEX_ENGINE_DEFINITION.capabilities).toContain('external_cli_tools'); + }); + + it('has settings with approvalPolicy field', () => { + expect(CODEX_ENGINE_DEFINITION.settings).toBeDefined(); + const fields = CODEX_ENGINE_DEFINITION.settings?.fields ?? []; + const keys = fields.map((f) => f.key); + expect(keys).toContain('approvalPolicy'); + }); + + it('approvalPolicy field has never option', () => { + const fields = CODEX_ENGINE_DEFINITION.settings?.fields ?? []; + const approvalField = fields.find((f) => f.key === 'approvalPolicy'); + expect(approvalField).toBeDefined(); + const options = (approvalField as { options?: { value: string }[] })?.options ?? []; + const values = options.map((o) => o.value); + expect(values).toContain('never'); + }); + + it('has sandboxMode setting field', () => { + const fields = CODEX_ENGINE_DEFINITION.settings?.fields ?? []; + const keys = fields.map((f) => f.key); + expect(keys).toContain('sandboxMode'); + }); +}); + +describe('OPENCODE_ENGINE_DEFINITION', () => { + it('has correct id and label', () => { + expect(OPENCODE_ENGINE_DEFINITION.id).toBe('opencode'); + expect(OPENCODE_ENGINE_DEFINITION.label).toBe('OpenCode'); + }); + + it('has free-text model selection', () => { + expect(OPENCODE_ENGINE_DEFINITION.modelSelection.type).toBe('free-text'); + }); + + it('has permission_policy capability', () => { + expect(OPENCODE_ENGINE_DEFINITION.capabilities).toContain('permission_policy'); + }); + + it('has native file edit and external CLI capabilities', () => { + expect(OPENCODE_ENGINE_DEFINITION.capabilities).toContain('native_file_edit_tools'); + expect(OPENCODE_ENGINE_DEFINITION.capabilities).toContain('external_cli_tools'); + }); + + it('has settings with webSearch field', () => { + expect(OPENCODE_ENGINE_DEFINITION.settings).toBeDefined(); + const fields = OPENCODE_ENGINE_DEFINITION.settings?.fields ?? []; + const keys = fields.map((f) => f.key); + expect(keys).toContain('webSearch'); + }); + + it('webSearch field is a boolean type', () => { + const fields = OPENCODE_ENGINE_DEFINITION.settings?.fields ?? []; + const webSearchField = fields.find((f) => f.key === 'webSearch'); + expect(webSearchField?.type).toBe('boolean'); + }); +}); + +// ─── Cross-cutting properties ───────────────────────────────────────────────── +describe('Engine definitions cross-cutting properties', () => { + it('all engines have scoped_env_secrets capability', () => { + for (const engine of DEFAULT_ENGINE_CATALOG) { + expect(engine.capabilities).toContain('scoped_env_secrets'); + } + }); + + it('native-tool engines have streaming_text_events and streaming_tool_events', () => { + const nativeToolEngines: AgentEngineDefinition[] = [ + CLAUDE_CODE_ENGINE_DEFINITION, + CODEX_ENGINE_DEFINITION, + OPENCODE_ENGINE_DEFINITION, + ]; + for (const engine of nativeToolEngines) { + expect(engine.capabilities).toContain('streaming_text_events'); + expect(engine.capabilities).toContain('streaming_tool_events'); + } + }); + + it('native-tool engines have external_cli_tools capability', () => { + const nativeToolEngines = [ + CLAUDE_CODE_ENGINE_DEFINITION, + CODEX_ENGINE_DEFINITION, + OPENCODE_ENGINE_DEFINITION, + ]; + for (const engine of nativeToolEngines) { + expect(engine.capabilities).toContain('external_cli_tools'); + } + }); + + it('all settings fields have key, label, and type', () => { + for (const engine of DEFAULT_ENGINE_CATALOG) { + if (!engine.settings) continue; + for (const field of engine.settings.fields) { + expect(typeof field.key).toBe('string'); + expect(field.key.length).toBeGreaterThan(0); + expect(typeof field.label).toBe('string'); + expect(field.label.length).toBeGreaterThan(0); + expect(typeof field.type).toBe('string'); + expect(field.type.length).toBeGreaterThan(0); + } + } + }); + + it('select-type settings fields have a non-empty options array', () => { + for (const engine of DEFAULT_ENGINE_CATALOG) { + if (!engine.settings) continue; + for (const field of engine.settings.fields) { + if (field.type === 'select') { + const opts = (field as { options?: unknown[] }).options; + expect(Array.isArray(opts)).toBe(true); + expect((opts as unknown[]).length).toBeGreaterThan(0); + } + } + } + }); +}); diff --git a/tests/unit/backends/shared-nativeToolPrompts.test.ts b/tests/unit/backends/shared-nativeToolPrompts.test.ts new file mode 100644 index 00000000..135b13c5 --- /dev/null +++ b/tests/unit/backends/shared-nativeToolPrompts.test.ts @@ -0,0 +1,402 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ToolManifest } from '../../../src/agents/contracts/index.js'; +import type { ContextInjection } from '../../../src/agents/contracts/index.js'; + +// Mock contextFiles module to avoid filesystem I/O +vi.mock('../../../src/backends/shared/contextFiles.js', () => ({ + buildInlineContextSection: vi.fn((injections: ContextInjection[]) => { + if (injections.length === 0) return ''; + let section = '\n\n## Pre-loaded Context\n'; + for (const inj of injections) { + section += `\n### ${inj.description} (${inj.toolName})\n`; + section += `Parameters: ${JSON.stringify(inj.params)}\n`; + section += `\`\`\`\n${inj.result}\n\`\`\`\n`; + } + return section; + }), + offloadLargeContext: vi.fn(), +})); + +import { + buildInlineContextSection, + offloadLargeContext, +} from '../../../src/backends/shared/contextFiles.js'; +import { + buildSystemPrompt, + buildTaskPrompt, + buildToolGuidance, +} from '../../../src/backends/shared/nativeToolPrompts.js'; + +// ───────── helper ───────── +function makeManifest(overrides: Partial = {}): ToolManifest { + return { + name: 'ReadWorkItem', + description: 'Read a work item.', + cliCommand: 'cascade-tools pm read-work-item', + parameters: { + workItemId: { type: 'string', required: true, description: 'The work item ID' }, + }, + ...overrides, + }; +} + +// ───────── formatParam (tested indirectly through buildToolGuidance) ───────── +describe('buildToolGuidance', () => { + it('returns empty string for empty tools array', () => { + expect(buildToolGuidance([])).toBe(''); + }); + + it('includes the CASCADE Tools heading', () => { + const result = buildToolGuidance([makeManifest()]); + expect(result).toContain('## CASCADE Tools'); + }); + + it('includes cascade-tools critical note', () => { + const result = buildToolGuidance([makeManifest()]); + expect(result).toContain('CRITICAL'); + expect(result).toContain('cascade-tools'); + }); + + it('includes tool name as a section heading', () => { + const result = buildToolGuidance([makeManifest({ name: 'PostComment' })]); + expect(result).toContain('### PostComment'); + }); + + it('includes tool description', () => { + const result = buildToolGuidance([makeManifest({ description: 'Post a comment.' })]); + expect(result).toContain('Post a comment.'); + }); + + it('includes cliCommand', () => { + const result = buildToolGuidance([ + makeManifest({ cliCommand: 'cascade-tools pm post-comment' }), + ]); + expect(result).toContain('cascade-tools pm post-comment'); + }); + + it('wraps output in markdown code block', () => { + const result = buildToolGuidance([makeManifest()]); + expect(result).toContain('```bash'); + expect(result).toContain('```'); + }); + + it('includes multiple tools', () => { + const tools = [ + makeManifest({ name: 'ToolA', cliCommand: 'cascade-tools pm tool-a' }), + makeManifest({ name: 'ToolB', cliCommand: 'cascade-tools pm tool-b' }), + ]; + const result = buildToolGuidance(tools); + expect(result).toContain('### ToolA'); + expect(result).toContain('### ToolB'); + }); + + describe('formatParam — required string param', () => { + it('formats required string param without brackets', () => { + const result = buildToolGuidance([ + makeManifest({ + parameters: { + workItemId: { type: 'string', required: true, description: 'The work item ID' }, + }, + }), + ]); + expect(result).toContain(' --workItemId '); + expect(result).not.toContain('[--workItemId'); + }); + + it('includes description as a comment', () => { + const result = buildToolGuidance([ + makeManifest({ + parameters: { + workItemId: { type: 'string', required: true, description: 'The work item ID' }, + }, + }), + ]); + expect(result).toContain('# The work item ID'); + }); + }); + + describe('formatParam — optional string param', () => { + it('formats optional string param with brackets', () => { + const result = buildToolGuidance([ + makeManifest({ + parameters: { + base: { type: 'string', required: false }, + }, + }), + ]); + expect(result).toContain('[--base ]'); + }); + }); + + describe('formatParam — array param (repeatable)', () => { + it('formats required array param as repeatable', () => { + const result = buildToolGuidance([ + makeManifest({ + parameters: { + items: { type: 'array', required: true }, + }, + }), + ]); + // Singular of 'items' is 'item' + expect(result).toContain('--item (repeatable)'); + expect(result).not.toContain('[--item'); + }); + + it('formats optional array param as repeatable with brackets', () => { + const result = buildToolGuidance([ + makeManifest({ + parameters: { + labels: { type: 'array', required: false }, + }, + }), + ]); + // Singular of 'labels' is 'label' + expect(result).toContain('[--label (repeatable)]'); + }); + }); + + describe('formatParam — boolean param', () => { + it('formats boolean param with default=true as --no-param', () => { + const result = buildToolGuidance([ + makeManifest({ + parameters: { + commit: { type: 'boolean', default: true }, + }, + }), + ]); + expect(result).toContain('[--no-commit]'); + }); + + it('formats boolean param with default=false as --param', () => { + const result = buildToolGuidance([ + makeManifest({ + parameters: { + draft: { type: 'boolean', default: false }, + }, + }), + ]); + expect(result).toContain('[--draft]'); + }); + + it('formats boolean param without default as --param', () => { + const result = buildToolGuidance([ + makeManifest({ + parameters: { + verbose: { type: 'boolean' }, + }, + }), + ]); + expect(result).toContain('[--verbose]'); + }); + }); + + describe('formatParam — no description', () => { + it('does not include # comment when description is absent', () => { + const result = buildToolGuidance([ + makeManifest({ + parameters: { + workItemId: { type: 'string', required: true }, + }, + }), + ]); + // The param line itself should not contain a # comment (only markdown headings use #) + const paramLine = result.split('\n').find((line) => line.includes('--workItemId')); + expect(paramLine).toBeDefined(); + expect(paramLine).not.toContain('#'); + }); + }); +}); + +// ───────── buildSystemPrompt ───────── +describe('buildSystemPrompt', () => { + it('prepends native tool execution rules', () => { + const result = buildSystemPrompt('My agent prompt.', []); + expect(result).toContain('## Native Tool Execution Rules'); + }); + + it('includes the system prompt text', () => { + const result = buildSystemPrompt('My agent prompt.', []); + expect(result).toContain('My agent prompt.'); + }); + + it('native tool rules appear before system prompt', () => { + const result = buildSystemPrompt('AGENT_MARKER', []); + const rulesPos = result.indexOf('## Native Tool Execution Rules'); + const agentPos = result.indexOf('AGENT_MARKER'); + expect(rulesPos).toBeLessThan(agentPos); + }); + + it('appends tool guidance when tools are provided', () => { + const tools = [makeManifest({ name: 'ReadWorkItem' })]; + const result = buildSystemPrompt('Agent prompt.', tools); + expect(result).toContain('## CASCADE Tools'); + expect(result).toContain('### ReadWorkItem'); + }); + + it('does not append tool guidance when tools array is empty', () => { + const result = buildSystemPrompt('Agent prompt.', []); + expect(result).not.toContain('## CASCADE Tools'); + }); + + it('tool guidance appears after system prompt', () => { + const tools = [makeManifest()]; + const result = buildSystemPrompt('AGENT_MARKER', tools); + const agentPos = result.indexOf('AGENT_MARKER'); + const toolsPos = result.indexOf('## CASCADE Tools'); + expect(agentPos).toBeLessThan(toolsPos); + }); + + it('blocks pseudo tool call instruction is included', () => { + const result = buildSystemPrompt('Agent prompt.', []); + expect(result).toContain('Never write pseudo tool calls'); + }); +}); + +// ───────── buildTaskPrompt ───────── +describe('buildTaskPrompt', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('returns task prompt unchanged when no context injections', async () => { + const result = await buildTaskPrompt('Do the task.', [], '/repo'); + expect(result.prompt).toBe('Do the task.'); + expect(result.hasOffloadedContext).toBe(false); + }); + + it('does not call offloadLargeContext when injections is empty', async () => { + await buildTaskPrompt('Do the task.', [], '/repo'); + expect(offloadLargeContext).not.toHaveBeenCalled(); + }); + + it('calls offloadLargeContext with repoDir and injections', async () => { + const injections: ContextInjection[] = [ + { + toolName: 'ReadWorkItem', + params: { workItemId: 'abc' }, + result: 'data', + description: 'Work item data', + }, + ]; + vi.mocked(offloadLargeContext).mockResolvedValueOnce({ + inlineInjections: injections, + offloadedFiles: [], + offloadedImages: [], + instructions: '', + }); + vi.mocked(buildInlineContextSection).mockReturnValueOnce('\n\ninline section'); + + await buildTaskPrompt('Do the task.', injections, '/repo'); + expect(offloadLargeContext).toHaveBeenCalledWith('/repo', injections); + }); + + it('appends inline context section to prompt', async () => { + const injections: ContextInjection[] = [ + { toolName: 'ReadWorkItem', params: {}, result: 'card content', description: 'Card data' }, + ]; + vi.mocked(offloadLargeContext).mockResolvedValueOnce({ + inlineInjections: injections, + offloadedFiles: [], + offloadedImages: [], + instructions: '', + }); + vi.mocked(buildInlineContextSection).mockReturnValueOnce('\n\n## Inline Context'); + + const result = await buildTaskPrompt('Base prompt.', injections, '/repo'); + expect(result.prompt).toContain('Base prompt.'); + expect(result.prompt).toContain('## Inline Context'); + }); + + it('appends offload instructions when instructions are present', async () => { + const injections: ContextInjection[] = [ + { + toolName: 'ReadWorkItem', + params: {}, + result: 'x'.repeat(5000), + description: 'Big context', + }, + ]; + vi.mocked(offloadLargeContext).mockResolvedValueOnce({ + inlineInjections: [], + offloadedFiles: [ + { + relativePath: '.cascade/context/big-context-0.txt', + description: 'Big context', + tokens: 1250, + }, + ], + offloadedImages: [], + instructions: '## Context Files\n\nRead these files.', + }); + vi.mocked(buildInlineContextSection).mockReturnValueOnce(''); + + const result = await buildTaskPrompt('Base prompt.', injections, '/repo'); + expect(result.prompt).toContain('## Context Files'); + expect(result.prompt).toContain('Read these files.'); + }); + + it('sets hasOffloadedContext=true when offloaded files are present', async () => { + const injections: ContextInjection[] = [ + { + toolName: 'ReadWorkItem', + params: {}, + result: 'x'.repeat(5000), + description: 'Big context', + }, + ]; + vi.mocked(offloadLargeContext).mockResolvedValueOnce({ + inlineInjections: [], + offloadedFiles: [ + { + relativePath: '.cascade/context/big-context-0.txt', + description: 'Big context', + tokens: 1250, + }, + ], + offloadedImages: [], + instructions: 'Read these files.', + }); + vi.mocked(buildInlineContextSection).mockReturnValueOnce(''); + + const result = await buildTaskPrompt('Base prompt.', injections, '/repo'); + expect(result.hasOffloadedContext).toBe(true); + }); + + it('sets hasOffloadedContext=true when offloaded images are present', async () => { + const injections: ContextInjection[] = [ + { + toolName: 'ReadWorkItem', + params: {}, + result: 'content', + description: 'Context with image', + images: [{ base64Data: 'abc', mimeType: 'image/png' }], + }, + ]; + vi.mocked(offloadLargeContext).mockResolvedValueOnce({ + inlineInjections: injections, + offloadedFiles: [], + offloadedImages: [{ relativePath: '.cascade/context/images/ctx-0-img-0.png' }], + instructions: 'Read these images.', + }); + vi.mocked(buildInlineContextSection).mockReturnValueOnce('\n\ninline section'); + + const result = await buildTaskPrompt('Base prompt.', injections, '/repo'); + expect(result.hasOffloadedContext).toBe(true); + }); + + it('sets hasOffloadedContext=false when nothing is offloaded', async () => { + const injections: ContextInjection[] = [ + { toolName: 'ReadWorkItem', params: {}, result: 'small', description: 'Small context' }, + ]; + vi.mocked(offloadLargeContext).mockResolvedValueOnce({ + inlineInjections: injections, + offloadedFiles: [], + offloadedImages: [], + instructions: '', + }); + vi.mocked(buildInlineContextSection).mockReturnValueOnce('\n\ninline section'); + + const result = await buildTaskPrompt('Base prompt.', injections, '/repo'); + expect(result.hasOffloadedContext).toBe(false); + }); +}); diff --git a/tests/unit/gadgets/github/definitions.test.ts b/tests/unit/gadgets/github/definitions.test.ts new file mode 100644 index 00000000..b133349d --- /dev/null +++ b/tests/unit/gadgets/github/definitions.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest'; +import { + createPRDef, + createPRReviewDef, + getCIRunLogsDef, + getPRChecksDef, + getPRCommentsDef, + getPRDetailsDef, + getPRDiffDef, + postPRCommentDef, + replyToReviewCommentDef, + updatePRCommentDef, +} from '../../../../src/gadgets/github/definitions.js'; +import type { ToolDefinition } from '../../../../src/gadgets/shared/toolDefinition.js'; + +const ALL_SCM_DEFINITIONS: ToolDefinition[] = [ + createPRDef, + createPRReviewDef, + getCIRunLogsDef, + getPRChecksDef, + getPRCommentsDef, + getPRDetailsDef, + getPRDiffDef, + postPRCommentDef, + replyToReviewCommentDef, + updatePRCommentDef, +]; + +describe('GitHub SCM gadget definitions', () => { + describe('all definitions integrity', () => { + it('exports exactly 10 definitions', () => { + expect(ALL_SCM_DEFINITIONS).toHaveLength(10); + }); + + it('all definitions have unique names', () => { + const names = ALL_SCM_DEFINITIONS.map((d) => d.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(names.length); + }); + + it('every definition has a non-empty name', () => { + for (const def of ALL_SCM_DEFINITIONS) { + expect(typeof def.name).toBe('string'); + expect(def.name.length).toBeGreaterThan(0); + } + }); + + it('every definition has a non-empty description', () => { + for (const def of ALL_SCM_DEFINITIONS) { + expect(typeof def.description).toBe('string'); + expect(def.description.length).toBeGreaterThan(0); + } + }); + + it('every definition has a timeoutMs greater than 0', () => { + for (const def of ALL_SCM_DEFINITIONS) { + if (def.timeoutMs !== undefined) { + expect(def.timeoutMs).toBeGreaterThan(0); + } + } + }); + + it('every definition has a parameters object', () => { + for (const def of ALL_SCM_DEFINITIONS) { + expect(typeof def.parameters).toBe('object'); + expect(def.parameters).not.toBeNull(); + } + }); + + it('every definition has at least one example', () => { + for (const def of ALL_SCM_DEFINITIONS) { + expect(Array.isArray(def.examples)).toBe(true); + expect((def.examples ?? []).length).toBeGreaterThan(0); + } + }); + + it('all definition names are PascalCase', () => { + for (const def of ALL_SCM_DEFINITIONS) { + expect(def.name).toMatch(/^[A-Z][a-zA-Z0-9]+$/); + } + }); + + it('all parameter descriptions are non-empty', () => { + for (const def of ALL_SCM_DEFINITIONS) { + for (const [paramName, paramDef] of Object.entries(def.parameters)) { + expect( + typeof paramDef.describe === 'string' && paramDef.describe.length > 0, + `Parameter '${paramName}' in '${def.name}' must have a non-empty describe`, + ).toBe(true); + } + } + }); + + it('every param with gadgetOnly=true is the comment field', () => { + for (const def of ALL_SCM_DEFINITIONS) { + for (const [paramName, paramDef] of Object.entries(def.parameters)) { + if (paramDef.gadgetOnly) { + expect(paramName).toBe('comment'); + } + } + } + }); + }); + + describe('expected tool names are present', () => { + it('includes CreatePR', () => { + expect(ALL_SCM_DEFINITIONS.map((d) => d.name)).toContain('CreatePR'); + }); + + it('includes CreatePRReview', () => { + expect(ALL_SCM_DEFINITIONS.map((d) => d.name)).toContain('CreatePRReview'); + }); + + it('includes GetPRDetails', () => { + expect(ALL_SCM_DEFINITIONS.map((d) => d.name)).toContain('GetPRDetails'); + }); + + it('includes GetPRDiff', () => { + expect(ALL_SCM_DEFINITIONS.map((d) => d.name)).toContain('GetPRDiff'); + }); + + it('includes GetPRChecks', () => { + expect(ALL_SCM_DEFINITIONS.map((d) => d.name)).toContain('GetPRChecks'); + }); + + it('includes GetPRComments', () => { + expect(ALL_SCM_DEFINITIONS.map((d) => d.name)).toContain('GetPRComments'); + }); + + it('includes PostPRComment', () => { + expect(ALL_SCM_DEFINITIONS.map((d) => d.name)).toContain('PostPRComment'); + }); + + it('includes UpdatePRComment', () => { + expect(ALL_SCM_DEFINITIONS.map((d) => d.name)).toContain('UpdatePRComment'); + }); + + it('includes ReplyToReviewComment', () => { + expect(ALL_SCM_DEFINITIONS.map((d) => d.name)).toContain('ReplyToReviewComment'); + }); + + it('includes GetCIRunLogs', () => { + expect(ALL_SCM_DEFINITIONS.map((d) => d.name)).toContain('GetCIRunLogs'); + }); + }); + + // ─── CreatePR specific ──────────────────────────────────────────────────── + describe('createPRDef', () => { + it('has required title, body, and head parameters', () => { + expect(createPRDef.parameters.title?.required).toBe(true); + expect(createPRDef.parameters.body?.required).toBe(true); + expect(createPRDef.parameters.head?.required).toBe(true); + }); + + it('has optional base parameter', () => { + expect(createPRDef.parameters.base?.optional).toBe(true); + }); + + it('has optional draft boolean parameter', () => { + expect(createPRDef.parameters.draft?.type).toBe('boolean'); + expect(createPRDef.parameters.draft?.optional).toBe(true); + }); + + it('has commit and push boolean parameters with default=true', () => { + expect(createPRDef.parameters.commit?.type).toBe('boolean'); + expect((createPRDef.parameters.commit as { default?: boolean })?.default).toBe(true); + expect(createPRDef.parameters.push?.type).toBe('boolean'); + expect((createPRDef.parameters.push as { default?: boolean })?.default).toBe(true); + }); + + it('has a 4-minute timeout (hooks may run test suites)', () => { + expect(createPRDef.timeoutMs).toBe(240000); + }); + + it('has body file input alternative in CLI', () => { + const bodyAlt = createPRDef.cli?.fileInputAlternatives?.find((a) => a.paramName === 'body'); + expect(bodyAlt).toBeDefined(); + expect(bodyAlt?.fileFlag).toBe('body-file'); + }); + }); + + // ─── CreatePRReview specific ────────────────────────────────────────────── + describe('createPRReviewDef', () => { + it('has required prNumber, event, and body parameters', () => { + expect(createPRReviewDef.parameters.prNumber?.required).toBe(true); + expect(createPRReviewDef.parameters.event?.required).toBe(true); + expect(createPRReviewDef.parameters.body?.required).toBe(true); + }); + + it('event parameter is an enum with APPROVE, REQUEST_CHANGES, COMMENT', () => { + const eventParam = createPRReviewDef.parameters.event; + expect(eventParam?.type).toBe('enum'); + const options = (eventParam as { options?: string[] })?.options ?? []; + expect(options).toContain('APPROVE'); + expect(options).toContain('REQUEST_CHANGES'); + expect(options).toContain('COMMENT'); + }); + + it('has optional comments array parameter', () => { + expect(createPRReviewDef.parameters.comments?.type).toBe('array'); + expect(createPRReviewDef.parameters.comments?.optional).toBe(true); + }); + + it('has auto-resolved owner and repo parameters', () => { + const autoResolved = createPRReviewDef.cli?.autoResolved ?? []; + const params = autoResolved.map((a) => a.paramName); + expect(params).toContain('owner'); + expect(params).toContain('repo'); + }); + }); + + // ─── GetCIRunLogs specific ──────────────────────────────────────────────── + describe('getCIRunLogsDef', () => { + it('has required ref parameter', () => { + expect(getCIRunLogsDef.parameters.ref?.required).toBe(true); + expect(getCIRunLogsDef.parameters.ref?.type).toBe('string'); + }); + + it('has auto-resolved owner and repo', () => { + const autoResolved = getCIRunLogsDef.cli?.autoResolved ?? []; + const params = autoResolved.map((a) => a.paramName); + expect(params).toContain('owner'); + expect(params).toContain('repo'); + }); + }); + + // ─── PostPRComment specific ─────────────────────────────────────────────── + describe('postPRCommentDef', () => { + it('has required prNumber and body parameters', () => { + expect(postPRCommentDef.parameters.prNumber?.required).toBe(true); + expect(postPRCommentDef.parameters.body?.required).toBe(true); + }); + + it('has body file input alternative', () => { + const bodyAlt = postPRCommentDef.cli?.fileInputAlternatives?.find( + (a) => a.paramName === 'body', + ); + expect(bodyAlt).toBeDefined(); + }); + }); + + // ─── ReplyToReviewComment specific ─────────────────────────────────────── + describe('replyToReviewCommentDef', () => { + it('has required prNumber, commentId, and body parameters', () => { + expect(replyToReviewCommentDef.parameters.prNumber?.required).toBe(true); + expect(replyToReviewCommentDef.parameters.commentId?.required).toBe(true); + expect(replyToReviewCommentDef.parameters.body?.required).toBe(true); + }); + }); + + // ─── UpdatePRComment specific ───────────────────────────────────────────── + describe('updatePRCommentDef', () => { + it('has required commentId and body parameters', () => { + expect(updatePRCommentDef.parameters.commentId?.required).toBe(true); + expect(updatePRCommentDef.parameters.body?.required).toBe(true); + }); + + it('does not have prNumber (comment ID is enough)', () => { + expect(updatePRCommentDef.parameters.prNumber).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/gadgets/pm/definitions.test.ts b/tests/unit/gadgets/pm/definitions.test.ts new file mode 100644 index 00000000..1ca8e8f5 --- /dev/null +++ b/tests/unit/gadgets/pm/definitions.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest'; +import { + addChecklistDef, + createWorkItemDef, + listWorkItemsDef, + moveWorkItemDef, + pmDeleteChecklistItemDef, + pmUpdateChecklistItemDef, + postCommentDef, + readWorkItemDef, + updateWorkItemDef, +} from '../../../../src/gadgets/pm/definitions.js'; +import type { ToolDefinition } from '../../../../src/gadgets/shared/toolDefinition.js'; + +const ALL_PM_DEFINITIONS: ToolDefinition[] = [ + readWorkItemDef, + postCommentDef, + updateWorkItemDef, + createWorkItemDef, + listWorkItemsDef, + moveWorkItemDef, + addChecklistDef, + pmUpdateChecklistItemDef, + pmDeleteChecklistItemDef, +]; + +describe('PM gadget definitions', () => { + describe('all definitions integrity', () => { + it('exports exactly 9 definitions', () => { + expect(ALL_PM_DEFINITIONS).toHaveLength(9); + }); + + it('all definitions have unique names', () => { + const names = ALL_PM_DEFINITIONS.map((d) => d.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(names.length); + }); + + it('every definition has a non-empty name', () => { + for (const def of ALL_PM_DEFINITIONS) { + expect(typeof def.name).toBe('string'); + expect(def.name.length).toBeGreaterThan(0); + } + }); + + it('every definition has a non-empty description', () => { + for (const def of ALL_PM_DEFINITIONS) { + expect(typeof def.description).toBe('string'); + expect(def.description.length).toBeGreaterThan(0); + } + }); + + it('every definition has a timeoutMs greater than 0', () => { + for (const def of ALL_PM_DEFINITIONS) { + if (def.timeoutMs !== undefined) { + expect(def.timeoutMs).toBeGreaterThan(0); + } + } + }); + + it('every definition has a parameters object', () => { + for (const def of ALL_PM_DEFINITIONS) { + expect(typeof def.parameters).toBe('object'); + expect(def.parameters).not.toBeNull(); + } + }); + + it('every definition has at least one example', () => { + for (const def of ALL_PM_DEFINITIONS) { + expect(Array.isArray(def.examples)).toBe(true); + expect((def.examples ?? []).length).toBeGreaterThan(0); + } + }); + + it('all parameter descriptions are non-empty', () => { + for (const def of ALL_PM_DEFINITIONS) { + for (const [paramName, paramDef] of Object.entries(def.parameters)) { + expect( + typeof paramDef.describe === 'string' && paramDef.describe.length > 0, + `Parameter '${paramName}' in '${def.name}' must have a non-empty describe`, + ).toBe(true); + } + } + }); + + it('all definition names are PascalCase', () => { + for (const def of ALL_PM_DEFINITIONS) { + expect(def.name).toMatch(/^[A-Z][a-zA-Z0-9]+$/); + } + }); + }); + + describe('expected tool names are present', () => { + it('includes ReadWorkItem', () => { + expect(ALL_PM_DEFINITIONS.map((d) => d.name)).toContain('ReadWorkItem'); + }); + + it('includes PostComment', () => { + expect(ALL_PM_DEFINITIONS.map((d) => d.name)).toContain('PostComment'); + }); + + it('includes UpdateWorkItem', () => { + expect(ALL_PM_DEFINITIONS.map((d) => d.name)).toContain('UpdateWorkItem'); + }); + + it('includes CreateWorkItem', () => { + expect(ALL_PM_DEFINITIONS.map((d) => d.name)).toContain('CreateWorkItem'); + }); + + it('includes ListWorkItems', () => { + expect(ALL_PM_DEFINITIONS.map((d) => d.name)).toContain('ListWorkItems'); + }); + + it('includes MoveWorkItem', () => { + expect(ALL_PM_DEFINITIONS.map((d) => d.name)).toContain('MoveWorkItem'); + }); + + it('includes AddChecklist', () => { + expect(ALL_PM_DEFINITIONS.map((d) => d.name)).toContain('AddChecklist'); + }); + + it('includes PMUpdateChecklistItem', () => { + expect(ALL_PM_DEFINITIONS.map((d) => d.name)).toContain('PMUpdateChecklistItem'); + }); + + it('includes PMDeleteChecklistItem', () => { + expect(ALL_PM_DEFINITIONS.map((d) => d.name)).toContain('PMDeleteChecklistItem'); + }); + }); + + // ─── ReadWorkItem specific ──────────────────────────────────────────────── + describe('readWorkItemDef', () => { + it('has required workItemId parameter', () => { + expect(readWorkItemDef.parameters.workItemId?.required).toBe(true); + expect(readWorkItemDef.parameters.workItemId?.type).toBe('string'); + }); + + it('has optional includeComments boolean with default=true', () => { + const includeComments = readWorkItemDef.parameters.includeComments; + expect(includeComments?.type).toBe('boolean'); + expect(includeComments?.optional).toBe(true); + expect((includeComments as { default?: boolean })?.default).toBe(true); + }); + }); + + // ─── PostComment specific ───────────────────────────────────────────────── + describe('postCommentDef', () => { + it('has required workItemId and text parameters', () => { + expect(postCommentDef.parameters.workItemId?.required).toBe(true); + expect(postCommentDef.parameters.text?.required).toBe(true); + }); + + it('has text file input alternative', () => { + const textAlt = postCommentDef.cli?.fileInputAlternatives?.find( + (a) => a.paramName === 'text', + ); + expect(textAlt).toBeDefined(); + expect(textAlt?.fileFlag).toBe('text-file'); + }); + }); + + // ─── UpdateWorkItem specific ────────────────────────────────────────────── + describe('updateWorkItemDef', () => { + it('has required workItemId parameter', () => { + expect(updateWorkItemDef.parameters.workItemId?.required).toBe(true); + }); + + it('title and description are optional', () => { + expect(updateWorkItemDef.parameters.title?.optional).toBe(true); + expect(updateWorkItemDef.parameters.description?.optional).toBe(true); + }); + + it('addLabelId is an optional array parameter', () => { + expect(updateWorkItemDef.parameters.addLabelId?.type).toBe('array'); + expect(updateWorkItemDef.parameters.addLabelId?.optional).toBe(true); + }); + + it('has description file input alternative', () => { + const descAlt = updateWorkItemDef.cli?.fileInputAlternatives?.find( + (a) => a.paramName === 'description', + ); + expect(descAlt).toBeDefined(); + expect(descAlt?.fileFlag).toBe('description-file'); + }); + }); + + // ─── CreateWorkItem specific ────────────────────────────────────────────── + describe('createWorkItemDef', () => { + it('has required containerId and title parameters', () => { + expect(createWorkItemDef.parameters.containerId?.required).toBe(true); + expect(createWorkItemDef.parameters.title?.required).toBe(true); + }); + + it('description is optional', () => { + expect(createWorkItemDef.parameters.description?.optional).toBe(true); + }); + }); + + // ─── ListWorkItems specific ──────────────────────────────────────────────── + describe('listWorkItemsDef', () => { + it('has required containerId parameter', () => { + expect(listWorkItemsDef.parameters.containerId?.required).toBe(true); + }); + }); + + // ─── MoveWorkItem specific ───────────────────────────────────────────────── + describe('moveWorkItemDef', () => { + it('has required workItemId and destination parameters', () => { + expect(moveWorkItemDef.parameters.workItemId?.required).toBe(true); + expect(moveWorkItemDef.parameters.destination?.required).toBe(true); + }); + }); + + // ─── AddChecklist specific ───────────────────────────────────────────────── + describe('addChecklistDef', () => { + it('has required workItemId, checklistName, and item parameters', () => { + expect(addChecklistDef.parameters.workItemId?.required).toBe(true); + expect(addChecklistDef.parameters.checklistName?.required).toBe(true); + expect(addChecklistDef.parameters.item?.required).toBe(true); + }); + + it('item is an array type', () => { + expect(addChecklistDef.parameters.item?.type).toBe('array'); + }); + }); + + // ─── PMUpdateChecklistItem specific ──────────────────────────────────────── + describe('pmUpdateChecklistItemDef', () => { + it('has required workItemId, checkItemId, and state parameters', () => { + expect(pmUpdateChecklistItemDef.parameters.workItemId?.required).toBe(true); + expect(pmUpdateChecklistItemDef.parameters.checkItemId?.required).toBe(true); + expect(pmUpdateChecklistItemDef.parameters.state?.required).toBe(true); + }); + + it('state is an enum with complete and incomplete options', () => { + const state = pmUpdateChecklistItemDef.parameters.state; + expect(state?.type).toBe('enum'); + const options = (state as { options?: string[] })?.options ?? []; + expect(options).toContain('complete'); + expect(options).toContain('incomplete'); + }); + }); + + // ─── PMDeleteChecklistItem specific ──────────────────────────────────────── + describe('pmDeleteChecklistItemDef', () => { + it('has required workItemId and checkItemId parameters', () => { + expect(pmDeleteChecklistItemDef.parameters.workItemId?.required).toBe(true); + expect(pmDeleteChecklistItemDef.parameters.checkItemId?.required).toBe(true); + }); + }); +}); diff --git a/tests/unit/gadgets/tmux/TmuxControlClient.test.ts b/tests/unit/gadgets/tmux/TmuxControlClient.test.ts new file mode 100644 index 00000000..edb9a872 --- /dev/null +++ b/tests/unit/gadgets/tmux/TmuxControlClient.test.ts @@ -0,0 +1,487 @@ +import { EventEmitter } from 'node:events'; +import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EXIT_MARKER_PREFIX, EXIT_MARKER_SUFFIX } from '../../../../src/gadgets/tmux/constants.js'; + +// ─── Mock readline ───────────────────────────────────────────────────────────── +// readline.createInterface needs a real readable stream — mock it instead +const mockRl = { + on: vi.fn(), + close: vi.fn(), +}; +vi.mock('node:readline', () => ({ + createInterface: vi.fn(() => mockRl), +})); + +// ─── Mock child_process ──────────────────────────────────────────────────────── +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), + spawn: vi.fn(), +})); + +// ─── Mock the filterProcessEnv from claude-code env ────────────────────────── +vi.mock('../../../../src/backends/claude-code/env.js', () => ({ + filterProcessEnv: vi.fn((env: NodeJS.ProcessEnv) => env), +})); + +import { execSync, spawn } from 'node:child_process'; +import { TmuxControlClient } from '../../../../src/gadgets/tmux/TmuxControlClient.js'; + +/** + * Create a mock child process with stdin/stdout as event emitters. + */ +function createMockProcess() { + const stdout = new EventEmitter(); + const stdin = { + write: vi.fn(), + }; + + const proc = new EventEmitter() as unknown as { + stdin: typeof stdin; + stdout: typeof stdout; + exitCode: number | null; + kill: () => void; + on: (event: string, handler: (...args: unknown[]) => void) => void; + }; + Object.assign(proc, { + stdin, + stdout, + exitCode: null, + kill: vi.fn(), + }); + + return { proc, stdout, stdin }; +} + +describe('TmuxControlClient', () => { + let client: TmuxControlClient; + let mockExecSync: MockInstance; + let mockSpawn: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset readline mock + mockRl.on.mockImplementation(() => mockRl); + mockExecSync = vi.mocked(execSync); + mockSpawn = vi.mocked(spawn); + client = new TmuxControlClient(); + }); + + afterEach(() => { + client.disconnect(); + }); + + // ─── connect() ──────────────────────────────────────────────────────────── + describe('connect()', () => { + it('spawns tmux in control mode after creating session', async () => { + const { proc } = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + await client.connect(); + + expect(mockExecSync).toHaveBeenCalledWith( + expect.stringContaining('new-session'), + expect.any(Object), + ); + expect(mockSpawn).toHaveBeenCalledWith( + 'tmux', + ['-C', 'attach-session', '-t', expect.stringContaining('_cascade_control')], + expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }), + ); + }); + + it('is idempotent — calling connect() twice does not spawn twice', async () => { + const { proc } = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + await client.connect(); + await client.connect(); // Second call — already connected + + // spawn should only be called once + expect(mockSpawn).toHaveBeenCalledTimes(1); + }); + + it('throws when tmux process exits immediately', async () => { + const { proc } = createMockProcess(); + // Simulate immediate exit + proc.exitCode = 1; + mockSpawn.mockReturnValue(proc); + + await expect(client.connect()).rejects.toThrow(/Failed to connect to tmux/); + }); + + it('kills existing session before creating new one', async () => { + const { proc } = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + await client.connect(); + + // First execSync call should be the kill-session call + expect(mockExecSync).toHaveBeenCalledWith( + expect.stringContaining('kill-session'), + expect.any(Object), + ); + }); + }); + + // ─── checkExitMarker() ──────────────────────────────────────────────────── + describe('checkExitMarker()', () => { + it('returns exited=false when window has no mapping', () => { + const result = client.checkExitMarker('nonexistent-window'); + expect(result).toEqual({ exited: false, exitCode: 0 }); + }); + + it('detects exit marker with code 0 in output buffer', () => { + const privateClient = client as unknown as { + windowToPaneId: Map; + paneOutputs: Map; + }; + privateClient.windowToPaneId.set('test-window', '%1'); + privateClient.paneOutputs.set('%1', [ + 'some output\n', + `${EXIT_MARKER_PREFIX}0${EXIT_MARKER_SUFFIX}\n`, + ]); + + const result = client.checkExitMarker('test-window'); + expect(result).toEqual({ exited: true, exitCode: 0 }); + }); + + it('extracts non-zero exit code from marker', () => { + const privateClient = client as unknown as { + windowToPaneId: Map; + paneOutputs: Map; + }; + privateClient.windowToPaneId.set('fail-window', '%2'); + privateClient.paneOutputs.set('%2', [`${EXIT_MARKER_PREFIX}127${EXIT_MARKER_SUFFIX}`]); + + const result = client.checkExitMarker('fail-window'); + expect(result).toEqual({ exited: true, exitCode: 127 }); + }); + + it('returns exited=false when no exit marker in output', () => { + const privateClient = client as unknown as { + windowToPaneId: Map; + paneOutputs: Map; + }; + privateClient.windowToPaneId.set('running-window', '%3'); + privateClient.paneOutputs.set('%3', ['line 1\n', 'line 2\n', 'still running\n']); + + const result = client.checkExitMarker('running-window'); + expect(result).toEqual({ exited: false, exitCode: 0 }); + }); + + it('returns exited=false when pane has no output buffer', () => { + const privateClient = client as unknown as { + windowToPaneId: Map; + }; + privateClient.windowToPaneId.set('empty-window', '%4'); + // No paneOutputs entry + + const result = client.checkExitMarker('empty-window'); + expect(result).toEqual({ exited: false, exitCode: 0 }); + }); + }); + + // ─── getOutput() ────────────────────────────────────────────────────────── + describe('getOutput()', () => { + it('returns empty string for unknown window', () => { + expect(client.getOutput('nonexistent')).toBe(''); + }); + + it('returns joined and ANSI-stripped buffer contents', () => { + const privateClient = client as unknown as { + windowToPaneId: Map; + paneOutputs: Map; + }; + privateClient.windowToPaneId.set('my-window', '%1'); + privateClient.paneOutputs.set('%1', ['hello ', 'world\n']); + + const output = client.getOutput('my-window'); + expect(output).toBe('hello world\n'); + }); + + it('returns empty string when pane buffer is missing', () => { + const privateClient = client as unknown as { + windowToPaneId: Map; + }; + privateClient.windowToPaneId.set('no-buffer', '%9'); + // No paneOutputs entry + + expect(client.getOutput('no-buffer')).toBe(''); + }); + }); + + // ─── isConnected() / disconnect() ───────────────────────────────────────── + describe('isConnected() and disconnect()', () => { + it('returns false before connecting', () => { + expect(client.isConnected()).toBe(false); + }); + + it('disconnect() is safe to call when not connected', () => { + expect(() => client.disconnect()).not.toThrow(); + }); + + it('returns false after disconnect', async () => { + const { proc } = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + await client.connect(); + + client.disconnect(); + expect(client.isConnected()).toBe(false); + }); + }); + + // ─── getLastMessage() ───────────────────────────────────────────────────── + describe('getLastMessage()', () => { + it('returns empty string initially', () => { + expect(client.getLastMessage()).toBe(''); + }); + }); + + // ─── sendCommand() ──────────────────────────────────────────────────────── + describe('sendCommand()', () => { + it('throws when not connected and cannot reconnect', async () => { + // Connect will fail because spawn returns a proc with immediate exitCode + const { proc } = createMockProcess(); + proc.exitCode = 1; + mockSpawn.mockReturnValue(proc); + + await expect(client.sendCommand('list-windows')).rejects.toThrow(); + }); + }); + + // ─── windowExists() ─────────────────────────────────────────────────────── + describe('windowExists()', () => { + it('returns true when window is in the internal map', async () => { + const privateClient = client as unknown as { + windowToPaneId: Map; + }; + privateClient.windowToPaneId.set('known-window', '%1'); + + // Short-circuits via in-memory map lookup + const exists = await client.windowExists('known-window'); + expect(exists).toBe(true); + }); + }); + + // ─── parseLine protocol ──────────────────────────────────────────────────── + describe('parseLine protocol parsing', () => { + it('accumulates output lines between %begin and %end markers', () => { + const privateClient = client as unknown as { + parseLine: (line: string) => void; + currentBlock: { lines: string[] } | null; + pendingCommand: { + resolve: (v: string) => void; + reject: (e: Error) => void; + } | null; + }; + + let resolved: string | undefined; + privateClient.pendingCommand = { + resolve: (v) => { + resolved = v; + }, + reject: vi.fn(), + }; + + privateClient.parseLine('%begin 123 456'); + privateClient.parseLine('window-name-1'); + privateClient.parseLine('window-name-2'); + privateClient.parseLine('%end 123 456'); + + expect(resolved).toBe('window-name-1\nwindow-name-2'); + }); + + it('resolves with empty string for empty block', () => { + const privateClient = client as unknown as { + parseLine: (line: string) => void; + pendingCommand: { + resolve: (v: string) => void; + reject: (e: Error) => void; + } | null; + }; + + let resolved: string | undefined; + privateClient.pendingCommand = { + resolve: (v) => { + resolved = v; + }, + reject: vi.fn(), + }; + + privateClient.parseLine('%begin 100 200'); + privateClient.parseLine('%end 100 200'); + + expect(resolved).toBe(''); + }); + + it('buffers %output lines in paneOutputs', () => { + const privateClient = client as unknown as { + parseLine: (line: string) => void; + paneOutputs: Map; + }; + + // %output + // \012 is the octal escape for newline + privateClient.parseLine('%output %1 hello\\012world'); + + const buffer = privateClient.paneOutputs.get('%1'); + expect(buffer).toBeDefined(); + expect(buffer?.join('')).toContain('hello'); + }); + + it('stores %message content in lastMessage', () => { + const privateClient = client as unknown as { + parseLine: (line: string) => void; + lastMessage: string; + }; + + privateClient.parseLine('%message something happened'); + + expect(privateClient.lastMessage).toBe('something happened'); + }); + + it('ignores known notification prefixes silently', () => { + const privateClient = client as unknown as { + parseLine: (line: string) => void; + currentBlock: { lines: string[] } | null; + }; + + // No block open — these should just be ignored + privateClient.parseLine('%session-created $0'); + privateClient.parseLine('%window-add @0'); + privateClient.parseLine('%pane-mode-changed %0'); + privateClient.parseLine('%exit'); + privateClient.parseLine('%layout-change $0 @0'); + + // If we get here without error and block is null, the test passes + expect(privateClient.currentBlock).toBeNull(); + }); + + it('does not resolve pending command on %error when no pending command', () => { + const privateClient = client as unknown as { + parseLine: (line: string) => void; + pendingCommand: unknown; + }; + + // Should not throw even with no pending command + privateClient.parseLine('%begin 1 2'); + privateClient.parseLine('%error 1 2'); + + expect(privateClient.pendingCommand).toBeNull(); + }); + }); + + // ─── createWindow() wraps command in exit marker shell ─────────────────── + describe('createWindow()', () => { + it('issues new-window command and stores pane ID mapping', async () => { + const privateClient = client as unknown as { + sendCommand: (cmd: string) => Promise; + }; + + const sendCommandSpy = vi + .spyOn(privateClient, 'sendCommand') + .mockResolvedValueOnce('%1') // new-window response + .mockResolvedValueOnce(''); // set-option response + + const paneId = await client.createWindow('my-window', 'echo hello'); + + expect(paneId).toBe('%1'); + expect(sendCommandSpy).toHaveBeenCalledWith(expect.stringContaining('new-window')); + expect(sendCommandSpy).toHaveBeenCalledWith(expect.stringContaining('my-window')); + + // Should also store the mapping + expect(client.getOutput('my-window')).toBe(''); + + sendCommandSpy.mockRestore(); + }); + + it('base64-encodes command to handle special characters', async () => { + const privateClient = client as unknown as { + sendCommand: (cmd: string) => Promise; + }; + + const sendCommandSpy = vi + .spyOn(privateClient, 'sendCommand') + .mockResolvedValueOnce('%2') + .mockResolvedValueOnce(''); + + const command = 'echo "hello world" && ls -la'; + await client.createWindow('test-window', command); + + // The command should be base64-encoded in the sent command + const encodedCommand = Buffer.from(command).toString('base64'); + expect(sendCommandSpy).toHaveBeenCalledWith(expect.stringContaining(encodedCommand)); + + sendCommandSpy.mockRestore(); + }); + }); + + // ─── killWindow() ───────────────────────────────────────────────────────── + describe('killWindow()', () => { + it('removes window from internal maps and sends kill-window command', async () => { + const privateClient = client as unknown as { + windowToPaneId: Map; + paneOutputs: Map; + sendCommand: (cmd: string) => Promise; + }; + privateClient.windowToPaneId.set('to-kill', '%5'); + privateClient.paneOutputs.set('%5', ['some output']); + + const sendCommandSpy = vi.spyOn(privateClient, 'sendCommand').mockResolvedValueOnce(''); + + await client.killWindow('to-kill'); + + expect(privateClient.windowToPaneId.has('to-kill')).toBe(false); + expect(privateClient.paneOutputs.has('%5')).toBe(false); + expect(sendCommandSpy).toHaveBeenCalledWith(expect.stringContaining('kill-window')); + sendCommandSpy.mockRestore(); + }); + + it('still sends kill-window for windows not in internal map', async () => { + const privateClient = client as unknown as { + sendCommand: (cmd: string) => Promise; + }; + + const sendCommandSpy = vi.spyOn(privateClient, 'sendCommand').mockResolvedValueOnce(''); + + await client.killWindow('external-window'); + + expect(sendCommandSpy).toHaveBeenCalledWith(expect.stringContaining('kill-window')); + sendCommandSpy.mockRestore(); + }); + }); + + // ─── sendKeys() ────────────────────────────────────────────────────────── + describe('sendKeys()', () => { + it('sends send-keys command with Enter when enter=true', async () => { + const privateClient = client as unknown as { + sendCommand: (cmd: string) => Promise; + }; + + const sendCommandSpy = vi.spyOn(privateClient, 'sendCommand').mockResolvedValueOnce(''); + + await client.sendKeys('my-window', 'npm test', true); + + expect(sendCommandSpy).toHaveBeenCalledWith(expect.stringMatching(/send-keys.*Enter/)); + + sendCommandSpy.mockRestore(); + }); + + it('sends send-keys command without Enter when enter=false', async () => { + const privateClient = client as unknown as { + sendCommand: (cmd: string) => Promise; + }; + + const sendCommandSpy = vi.spyOn(privateClient, 'sendCommand').mockResolvedValueOnce(''); + + await client.sendKeys('my-window', 'C-c', false); + + const calledWith = sendCommandSpy.mock.calls[0][0] as string; + expect(calledWith).toContain('send-keys'); + expect(calledWith).not.toContain(' Enter'); + + sendCommandSpy.mockRestore(); + }); + }); +}); diff --git a/tests/unit/gadgets/tmux/TmuxGadget.test.ts b/tests/unit/gadgets/tmux/TmuxGadget.test.ts new file mode 100644 index 00000000..3431f809 --- /dev/null +++ b/tests/unit/gadgets/tmux/TmuxGadget.test.ts @@ -0,0 +1,463 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CommandFailedError } from '../../../../src/gadgets/tmux/errors.js'; + +// ─── Mock the control client module ─────────────────────────────────────────── +const mockClient = { + windowExists: vi.fn<() => Promise>(), + createWindow: vi.fn<() => Promise>(), + killWindow: vi.fn<() => Promise>(), + sendKeys: vi.fn<() => Promise>(), + checkExitMarker: vi.fn<() => { exited: boolean; exitCode: number }>(), + isPaneDead: vi.fn<() => Promise<{ dead: boolean; exitCode: number }>>(), + getOutput: vi.fn<() => string>(), + capturePaneOutput: vi.fn<() => Promise>(), + getSessionStatus: + vi.fn<() => Promise<{ status: 'running' | 'exited' | 'not_found'; exitCode?: number }>>(), + listWindows: vi.fn<() => Promise>(), +}; + +vi.mock('../../../../src/gadgets/tmux/TmuxControlClient.js', () => ({ + getControlClient: vi.fn(async () => mockClient), +})); + +// ─── Mock sleep so tests don't actually wait ────────────────────────────────── +vi.mock('../../../../src/gadgets/tmux/utils.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + sleep: vi.fn(async () => {}), + }; +}); + +import { TmuxGadget } from '../../../../src/gadgets/tmux/TmuxGadget.js'; + +// Helper to invoke the gadget's execute method +async function execute(params: Record): Promise { + const gadget = new TmuxGadget(); + return gadget.execute(params as Parameters[0]); +} + +describe('TmuxGadget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ─── start action ───────────────────────────────────────────────────────── + describe('start action', () => { + it('returns running status when command does not exit within wait period', async () => { + mockClient.windowExists.mockResolvedValue(false); + mockClient.createWindow.mockResolvedValue('%1'); + // Exit marker never found, pane never dead — command keeps "running" + mockClient.checkExitMarker.mockReturnValue({ exited: false, exitCode: 0 }); + mockClient.isPaneDead.mockResolvedValue({ dead: false, exitCode: 0 }); + mockClient.getOutput.mockReturnValue('some output'); + mockClient.capturePaneOutput.mockResolvedValue(''); + + const result = await execute({ + action: 'start', + comment: 'test', + session: 'my-session', + command: 'npm test', + // Use a very short wait so the loop exits quickly in tests + wait: 100, + }); + + expect(result).toContain('status=running'); + expect(result).toContain('my-session'); + }); + + it('returns exited status with exit_code=0 on successful command', async () => { + mockClient.windowExists.mockResolvedValue(false); + mockClient.createWindow.mockResolvedValue('%1'); + // Exit marker detected immediately + mockClient.checkExitMarker.mockReturnValue({ exited: true, exitCode: 0 }); + mockClient.getOutput.mockReturnValue('all tests passed'); + mockClient.capturePaneOutput.mockResolvedValue(''); + mockClient.killWindow.mockResolvedValue(undefined); + + const result = await execute({ + action: 'start', + comment: 'test', + session: 'test-session', + command: 'npm test', + wait: 5000, + }); + + expect(result).toContain('status=exited'); + expect(result).toContain('exit_code=0'); + expect(result).toContain('all tests passed'); + }); + + it('throws CommandFailedError when command exits with non-zero code', async () => { + mockClient.windowExists.mockResolvedValue(false); + mockClient.createWindow.mockResolvedValue('%1'); + mockClient.checkExitMarker.mockReturnValue({ exited: true, exitCode: 1 }); + mockClient.getOutput.mockReturnValue('test failed'); + mockClient.capturePaneOutput.mockResolvedValue(''); + mockClient.killWindow.mockResolvedValue(undefined); + + await expect( + execute({ + action: 'start', + comment: 'test', + session: 'fail-session', + command: 'npm test', + wait: 5000, + }), + ).rejects.toThrow(CommandFailedError); + }); + + it('returns error when session already exists', async () => { + mockClient.windowExists.mockResolvedValue(true); + + const result = await execute({ + action: 'start', + comment: 'test', + session: 'existing-session', + command: 'echo hello', + wait: 5000, + }); + + expect(result).toContain('status=error'); + expect(result).toContain('existing-session'); + expect(result).toContain('already exists'); + }); + + it('returns error when pane creation fails (paneId does not start with %)', async () => { + mockClient.windowExists.mockResolvedValue(false); + mockClient.createWindow.mockResolvedValue('error: something went wrong'); + + const result = await execute({ + action: 'start', + comment: 'test', + session: 'bad-session', + command: 'echo hello', + wait: 5000, + }); + + expect(result).toContain('status=error'); + expect(result).toContain('Failed to create session'); + }); + + it('blocks git commands with --no-verify flag', async () => { + await expect( + execute({ + action: 'start', + comment: 'test', + session: 'git-session', + command: 'git commit --no-verify -m "test"', + wait: 5000, + }), + ).rejects.toThrow('--no-verify'); + }); + + it('uses isPaneDead fallback when exit marker is not found', async () => { + mockClient.windowExists.mockResolvedValue(false); + mockClient.createWindow.mockResolvedValue('%1'); + // No exit marker, but pane is dead + mockClient.checkExitMarker.mockReturnValue({ exited: false, exitCode: 0 }); + mockClient.isPaneDead.mockResolvedValue({ dead: true, exitCode: 0 }); + mockClient.getOutput.mockReturnValue('command output'); + mockClient.capturePaneOutput.mockResolvedValue(''); + mockClient.killWindow.mockResolvedValue(undefined); + + const result = await execute({ + action: 'start', + comment: 'test', + session: 'pane-dead-session', + command: 'echo done', + wait: 5000, + }); + + expect(result).toContain('status=exited'); + expect(result).toContain('exit_code=0'); + }); + }); + + // ─── capture action ─────────────────────────────────────────────────────── + describe('capture action', () => { + it('returns error when session does not exist', async () => { + mockClient.getSessionStatus.mockResolvedValue({ status: 'not_found' }); + + const result = await execute({ + action: 'capture', + comment: 'test', + session: 'nonexistent', + lines: 25, + }); + + expect(result).toContain('status=error'); + expect(result).toContain('nonexistent'); + expect(result).toContain('does not exist'); + }); + + it('returns running status for a running session', async () => { + mockClient.getSessionStatus.mockResolvedValue({ status: 'running' }); + mockClient.getOutput.mockReturnValue('output line 1\noutput line 2'); + mockClient.capturePaneOutput.mockResolvedValue(''); + + const result = await execute({ + action: 'capture', + comment: 'test', + session: 'running-session', + lines: 25, + }); + + expect(result).toContain('status=running'); + expect(result).toContain('lines=25'); + }); + + it('returns exited status with exit code for completed session', async () => { + mockClient.getSessionStatus.mockResolvedValue({ status: 'exited', exitCode: 0 }); + mockClient.getOutput.mockReturnValue('done output'); + mockClient.capturePaneOutput.mockResolvedValue(''); + + const result = await execute({ + action: 'capture', + comment: 'test', + session: 'done-session', + lines: 10, + }); + + expect(result).toContain('status=exited'); + expect(result).toContain('exit_code=0'); + expect(result).toContain('lines=10'); + }); + + it('respects lines parameter', async () => { + mockClient.getSessionStatus.mockResolvedValue({ status: 'running' }); + // Return many lines, capture should truncate to requested lines + const manyLines = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n'); + mockClient.getOutput.mockReturnValue(manyLines); + mockClient.capturePaneOutput.mockResolvedValue(''); + + const result = await execute({ + action: 'capture', + comment: 'test', + session: 'session-with-many-lines', + lines: 5, + }); + + expect(result).toContain('lines=5'); + }); + + it('falls back to capturePaneOutput when getOutput returns empty', async () => { + mockClient.getSessionStatus.mockResolvedValue({ status: 'running' }); + mockClient.getOutput.mockReturnValue(' '); + mockClient.capturePaneOutput.mockResolvedValue('captured from pane'); + + const result = await execute({ + action: 'capture', + comment: 'test', + session: 'empty-output-session', + lines: 25, + }); + + expect(result).toContain('captured from pane'); + }); + }); + + // ─── send action ────────────────────────────────────────────────────────── + describe('send action', () => { + it('returns error when session does not exist', async () => { + mockClient.windowExists.mockResolvedValue(false); + + const result = await execute({ + action: 'send', + comment: 'test', + session: 'nonexistent', + keys: 'C-c', + enter: false, + }); + + expect(result).toContain('status=error'); + expect(result).toContain('nonexistent'); + }); + + it('sends keys to existing session and returns sent status', async () => { + mockClient.windowExists.mockResolvedValue(true); + mockClient.sendKeys.mockResolvedValue(undefined); + + const result = await execute({ + action: 'send', + comment: 'test', + session: 'my-session', + keys: 'C-c', + enter: false, + }); + + expect(result).toContain('status=sent'); + expect(result).toContain('C-c'); + expect(mockClient.sendKeys).toHaveBeenCalledWith('my-session', 'C-c', false); + }); + + it('appends [Enter] note when enter=true', async () => { + mockClient.windowExists.mockResolvedValue(true); + mockClient.sendKeys.mockResolvedValue(undefined); + + const result = await execute({ + action: 'send', + comment: 'test', + session: 'my-session', + keys: 'npm test', + enter: true, + }); + + expect(result).toContain('[Enter]'); + }); + + it('blocks git commands with --no-verify when enter=true', async () => { + await expect( + execute({ + action: 'send', + comment: 'test', + session: 'git-session', + keys: 'git commit --no-verify -m "test"', + enter: true, + }), + ).rejects.toThrow('--no-verify'); + }); + + it('does not validate git command when enter=false', async () => { + mockClient.windowExists.mockResolvedValue(true); + mockClient.sendKeys.mockResolvedValue(undefined); + + // Should not throw even though keys looks like a dangerous git command + // when enter=false the command is not executed + await expect( + execute({ + action: 'send', + comment: 'test', + session: 'my-session', + keys: 'git commit --no-verify', + enter: false, + }), + ).resolves.toContain('status=sent'); + }); + }); + + // ─── kill action ────────────────────────────────────────────────────────── + describe('kill action', () => { + it('returns error when session does not exist', async () => { + mockClient.windowExists.mockResolvedValue(false); + + const result = await execute({ + action: 'kill', + comment: 'test', + session: 'nonexistent', + }); + + expect(result).toContain('status=error'); + }); + + it('kills existing session and returns killed status', async () => { + mockClient.windowExists.mockResolvedValue(true); + mockClient.killWindow.mockResolvedValue(undefined); + + const result = await execute({ + action: 'kill', + comment: 'test', + session: 'running-session', + }); + + expect(result).toContain('status=killed'); + expect(result).toContain('running-session'); + expect(mockClient.killWindow).toHaveBeenCalledWith('running-session'); + }); + }); + + // ─── list action ────────────────────────────────────────────────────────── + describe('list action', () => { + it('returns session count and names', async () => { + mockClient.listWindows.mockResolvedValue([ + '1:test-run npm (running)', + '2:npm-install npm (running)', + ]); + + const result = await execute({ + action: 'list', + comment: 'test', + }); + + expect(result).toContain('sessions=2'); + }); + + it('returns sessions=0 when no windows exist', async () => { + // listWindows filters out "0:" window, if it returns ["0: bash (running)"] + // the filtered result is empty + mockClient.listWindows.mockResolvedValue(['0: bash (running)']); + + const result = await execute({ + action: 'list', + comment: 'test', + }); + + expect(result).toContain('sessions=0'); + }); + + it('returns sessions=0 when listWindows throws', async () => { + mockClient.listWindows.mockRejectedValue(new Error('tmux error')); + + const result = await execute({ + action: 'list', + comment: 'test', + }); + + expect(result).toContain('sessions=0'); + }); + }); + + // ─── exists action ──────────────────────────────────────────────────────── + describe('exists action', () => { + it('returns exists=true for existing session', async () => { + mockClient.windowExists.mockResolvedValue(true); + + const result = await execute({ + action: 'exists', + comment: 'test', + session: 'my-session', + }); + + expect(result).toContain('exists=true'); + }); + + it('returns exists=false for nonexistent session', async () => { + mockClient.windowExists.mockResolvedValue(false); + + const result = await execute({ + action: 'exists', + comment: 'test', + session: 'nonexistent', + }); + + expect(result).toContain('exists=false'); + }); + }); + + // ─── session name sanitization ──────────────────────────────────────────── + describe('session name sanitization', () => { + it('sanitizes session names with slashes', async () => { + mockClient.windowExists.mockResolvedValue(false); + mockClient.createWindow.mockResolvedValue('%1'); + mockClient.checkExitMarker.mockReturnValue({ exited: true, exitCode: 0 }); + mockClient.getOutput.mockReturnValue('output'); + mockClient.capturePaneOutput.mockResolvedValue(''); + mockClient.killWindow.mockResolvedValue(undefined); + + await execute({ + action: 'start', + comment: 'test', + session: 'feature/branch-name', + command: 'echo hello', + wait: 5000, + }); + + // The createWindow should be called with sanitized name (- instead of /) + expect(mockClient.createWindow).toHaveBeenCalledWith( + expect.stringMatching(/^feature-branch-name$/), + 'echo hello', + undefined, + ); + }); + }); +});