diff --git a/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts b/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts index 1f715919..ae380d5e 100644 --- a/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for get_coverage_report tool - * Covers happy-path, target filtering, showFiles, and failure paths - */ - import { afterEach, describe, it, expect } from 'vitest'; import { createMockExecutor, createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; import { @@ -11,6 +6,9 @@ import { __clearTestExecutorOverrides, } from '../../../../utils/execution/index.ts'; import { schema, handler, get_coverage_reportLogic } from '../get_coverage_report.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + + const sampleTargets = [ { name: 'MyApp.app', coveredLines: 100, executableLines: 200, lineCoverage: 0.5 }, @@ -99,7 +97,7 @@ describe('get_coverage_report', () => { const result = await handler({ xcresultPath: '/tmp/missing.xcresult', showFiles: false }); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File not found'); }); @@ -137,10 +135,10 @@ describe('get_coverage_report', () => { }, }); - await get_coverage_reportLogic( + await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', showFiles: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(commands).toHaveLength(1); expect(commands[0]).toContain('--only-targets'); @@ -158,10 +156,10 @@ describe('get_coverage_report', () => { }, }); - await get_coverage_reportLogic( + await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', showFiles: true }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(commands).toHaveLength(1); expect(commands[0]).not.toContain('--only-targets'); @@ -175,15 +173,14 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', showFiles: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(1); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('Code Coverage Report'); + expect(result.content.length).toBeGreaterThanOrEqual(1); + const text = allText(result); expect(text).toContain('Overall: 24.7%'); expect(text).toContain('180/730 lines'); const coreIdx = text.indexOf('Core'); @@ -199,10 +196,10 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', showFiles: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.nextStepParams).toEqual({ get_file_coverage: { xcresultPath: '/tmp/test.xcresult' }, @@ -216,13 +213,14 @@ describe('get_coverage_report', () => { output: JSON.stringify(nestedData), }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', showFiles: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(result.content.length).toBeGreaterThan(0); + const text = allText(result); expect(text).toContain('Core: 10.0%'); expect(text).toContain('MyApp.app: 50.0%'); }); @@ -235,16 +233,16 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', target: 'MyApp', showFiles: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('MyApp.app'); - expect(text).toContain('MyAppTests.xctest'); - expect(text).not.toMatch(/^\s+Core:/m); + const text = allText(result); + expect(text.includes('MyApp.app')).toBe(true); + expect(text.includes('MyAppTests.xctest')).toBe(true); + expect(text.includes('Core:')).toBe(false); }); it('should filter case-insensitively', async () => { @@ -253,14 +251,13 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', target: 'core', showFiles: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('Core: 10.0%'); + expect(allText(result).includes('Core')).toBe(true); }); it('should return error when no targets match filter', async () => { @@ -269,13 +266,13 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', target: 'NonExistent', showFiles: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('No targets found matching "NonExistent"'); }); }); @@ -287,17 +284,17 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargetsWithFiles), }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', showFiles: true }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('AppDelegate.swift: 20.0%'); - expect(text).toContain('ViewModel.swift: 60.0%'); - expect(text).toContain('Service.swift: 0.0%'); - expect(text).toContain('Model.swift: 25.0%'); + const text = allText(result); + expect(text.includes('AppDelegate.swift')).toBe(true); + expect(text.includes('ViewModel.swift')).toBe(true); + expect(text.includes('Service.swift')).toBe(true); + expect(text.includes('Model.swift')).toBe(true); }); it('should sort files by coverage ascending within each target', async () => { @@ -306,12 +303,12 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargetsWithFiles), }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', showFiles: true }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); const appDelegateIdx = text.indexOf('AppDelegate.swift'); const viewModelIdx = text.indexOf('ViewModel.swift'); expect(appDelegateIdx).toBeLessThan(viewModelIdx); @@ -323,13 +320,13 @@ describe('get_coverage_report', () => { const missingFs = createMockFileSystemExecutor({ existsSync: () => false }); const mockExecutor = createMockExecutor({ success: true, output: '{}' }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/missing.xcresult', showFiles: false }, { executor: mockExecutor, fileSystem: missingFs }, - ); + )); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File not found'); expect(text).toContain('/tmp/missing.xcresult'); }); @@ -340,13 +337,13 @@ describe('get_coverage_report', () => { error: 'Failed to load result bundle', }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/bad.xcresult', showFiles: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Failed to get coverage report'); expect(text).toContain('Failed to load result bundle'); }); @@ -357,13 +354,13 @@ describe('get_coverage_report', () => { output: 'not valid json', }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', showFiles: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Failed to parse coverage JSON output'); }); @@ -373,13 +370,13 @@ describe('get_coverage_report', () => { output: JSON.stringify({ unexpected: 'format' }), }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', showFiles: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Unexpected coverage data format'); }); @@ -389,13 +386,13 @@ describe('get_coverage_report', () => { output: JSON.stringify([]), }); - const result = await get_coverage_reportLogic( + const result = await runLogic(() => get_coverage_reportLogic( { xcresultPath: '/tmp/test.xcresult', showFiles: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('No coverage data found'); }); }); diff --git a/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts index 611bb9f7..e6a4ac9c 100644 --- a/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for get_file_coverage tool - * Covers happy-path, showLines, uncovered line parsing, and failure paths - */ - import { afterEach, describe, it, expect } from 'vitest'; import { createMockExecutor, @@ -15,6 +10,9 @@ import { __clearTestExecutorOverrides, } from '../../../../utils/execution/index.ts'; import { schema, handler, get_file_coverageLogic } from '../get_file_coverage.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + + const sampleFunctionsJson = [ { @@ -102,7 +100,7 @@ describe('get_file_coverage', () => { }); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File not found'); }); @@ -140,10 +138,10 @@ describe('get_file_coverage', () => { }, }); - await get_file_coverageLogic( + await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(commands).toHaveLength(1); expect(commands[0]).toEqual([ @@ -180,10 +178,10 @@ describe('get_file_coverage', () => { return createMockCommandResponse({ success: true, output: sampleArchiveOutput, exitCode: 0 }); }; - await get_file_coverageLogic( + await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(commands).toHaveLength(2); expect(commands[1]).toEqual([ @@ -205,67 +203,68 @@ describe('get_file_coverage', () => { output: JSON.stringify(sampleFunctionsJson), }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File: /src/MyApp/ViewModel.swift'); expect(text).toContain('Coverage: 61.9%'); expect(text).toContain('13/21 lines'); }); - it('should mark uncovered functions with [NOT COVERED]', async () => { + it('should group uncovered functions under Not Covered section', async () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify(sampleFunctionsJson), }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('[NOT COVERED] L40 reset()'); - expect(text).not.toContain('[NOT COVERED] L10 init()'); + const text = allText(result); + expect(text).toContain('Not Covered (1 function, 4 lines)'); + expect(text).toContain('L40 reset()'); }); - it('should sort functions by line number', async () => { + it('should group functions by coverage status', async () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify(sampleFunctionsJson), }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); - - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - const initIdx = text.indexOf('L10 init()'); - const loadIdx = text.indexOf('L20 loadData()'); - const resetIdx = text.indexOf('L40 reset()'); - expect(initIdx).toBeLessThan(loadIdx); - expect(loadIdx).toBeLessThan(resetIdx); + )); + + const text = allText(result); + const notCoveredIdx = text.indexOf('Not Covered'); + const partialIdx = text.indexOf('Partial Coverage'); + const fullIdx = text.indexOf('Full Coverage'); + expect(notCoveredIdx).toBeLessThan(partialIdx); + expect(partialIdx).toBeLessThan(fullIdx); }); - it('should list uncovered functions summary', async () => { + it('should show partial coverage functions with percentage', async () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify(sampleFunctionsJson), }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('Uncovered functions (1):'); - expect(text).toContain('- reset() (line 40)'); + const text = allText(result); + expect(text).toContain('Partial Coverage (1 function)'); + expect(text).toContain('L20 loadData()'); + expect(text).toContain('66.7%'); }); it('should include nextStepParams', async () => { @@ -274,10 +273,10 @@ describe('get_file_coverage', () => { output: JSON.stringify(sampleFunctionsJson), }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.nextStepParams).toEqual({ get_coverage_report: { xcresultPath: '/tmp/test.xcresult' }, @@ -318,13 +317,13 @@ describe('get_file_coverage', () => { output: JSON.stringify(nestedData), }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Model.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File: /src/Model.swift'); expect(text).toContain('50.0%'); }); @@ -351,13 +350,13 @@ describe('get_file_coverage', () => { return createMockCommandResponse({ success: true, output: sampleArchiveOutput, exitCode: 0 }); }; - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('Uncovered line ranges (/src/MyApp/ViewModel.swift):'); + const text = allText(result); + expect(text).toContain('Uncovered line ranges (/src/MyApp/ViewModel.swift)'); expect(text).toContain('L4-6'); expect(text).toContain('L9'); }); @@ -383,12 +382,12 @@ describe('get_file_coverage', () => { return createMockCommandResponse({ success: true, output: allCoveredArchive, exitCode: 0 }); }; - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('All executable lines are covered'); }); @@ -417,13 +416,13 @@ describe('get_file_coverage', () => { }); }; - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Could not retrieve line-level coverage from archive'); }); }); @@ -433,13 +432,13 @@ describe('get_file_coverage', () => { const missingFs = createMockFileSystemExecutor({ existsSync: () => false }); const mockExecutor = createMockExecutor({ success: true, output: '{}' }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/missing.xcresult', file: 'Foo.swift', showLines: false }, { executor: mockExecutor, fileSystem: missingFs }, - ); + )); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File not found'); expect(text).toContain('/tmp/missing.xcresult'); }); @@ -450,13 +449,13 @@ describe('get_file_coverage', () => { error: 'Failed to load result bundle', }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/bad.xcresult', file: 'Foo.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Failed to get file coverage'); expect(text).toContain('Failed to load result bundle'); }); @@ -467,13 +466,13 @@ describe('get_file_coverage', () => { output: 'not json', }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Foo.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('Failed to parse coverage JSON output'); }); @@ -483,13 +482,13 @@ describe('get_file_coverage', () => { output: JSON.stringify([]), }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Missing.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('No coverage data found for "Missing.swift"'); }); @@ -499,13 +498,13 @@ describe('get_file_coverage', () => { output: JSON.stringify({ targets: 'not-an-array' }), }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Foo.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBe(true); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('No coverage data found for "Foo.swift"'); }); @@ -516,13 +515,13 @@ describe('get_file_coverage', () => { output: JSON.stringify(noFunctions), }); - const result = await get_file_coverageLogic( + const result = await runLogic(() => get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Empty.swift', showLines: false }, { executor: mockExecutor, fileSystem: mockFileSystem }, - ); + )); expect(result.isError).toBeUndefined(); - const text = result.content[0].type === 'text' ? result.content[0].text : ''; + const text = allText(result); expect(text).toContain('File: /src/Empty.swift'); expect(text).toContain('Coverage: 0.0%'); expect(text).toContain('0/0 lines'); diff --git a/src/mcp/tools/coverage/get_coverage_report.ts b/src/mcp/tools/coverage/get_coverage_report.ts index 6f3ce827..f6f6400d 100644 --- a/src/mcp/tools/coverage/get_coverage_report.ts +++ b/src/mcp/tools/coverage/get_coverage_report.ts @@ -6,12 +6,15 @@ */ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { validateFileExists } from '../../../utils/validation/index.ts'; +import { validateFileExists } from '../../../utils/validation.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/execution/index.ts'; -import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; const getCoverageReportSchema = z.object({ xcresultPath: z.string().describe('Path to the .xcresult bundle'), @@ -60,12 +63,21 @@ type GetCoverageReportContext = { export async function get_coverage_reportLogic( params: GetCoverageReportParams, context: GetCoverageReportContext, -): Promise { +): Promise { + const ctx = getHandlerContext(); const { xcresultPath, target, showFiles } = params; + const headerParams = [{ label: 'xcresult', value: xcresultPath }]; + if (target) { + headerParams.push({ label: 'Target Filter', value: target }); + } + const headerEvent = header('Coverage Report', headerParams); + const fileExistsValidation = validateFileExists(xcresultPath, context.fileSystem); if (!fileExistsValidation.isValid) { - return { content: [{ type: 'text', text: fileExistsValidation.errorMessage! }], isError: true }; + ctx.emit(headerEvent); + ctx.emit(statusLine('error', fileExistsValidation.errorMessage!)); + return; } log('info', `Getting coverage report from: ${xcresultPath}`); @@ -76,36 +88,25 @@ export async function get_coverage_reportLogic( } cmd.push('--json', xcresultPath); - const result = await context.executor(cmd, 'Get Coverage Report', false, undefined); + const result = await context.executor(cmd, 'Get Coverage Report', false); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Failed to get coverage report: ${result.error ?? result.output}\n\nMake sure the xcresult bundle exists and contains coverage data.\nHint: Run tests with coverage enabled (e.g., xcodebuild test -enableCodeCoverage YES).`, - }, - ], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to get coverage report: ${result.error ?? result.output}`)); + return; } let data: unknown; try { data = JSON.parse(result.output); } catch { - return { - content: [ - { - type: 'text', - text: `Failed to parse coverage JSON output.\n\nRaw output:\n${result.output}`, - }, - ], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit( + statusLine('error', `Failed to parse coverage JSON output.\n\nRaw output:\n${result.output}`), + ); + return; } - // Validate structure: expect an array of target objects or { targets: [...] } let rawTargets: unknown[] = []; if (Array.isArray(data)) { rawTargets = data; @@ -117,53 +118,34 @@ export async function get_coverage_reportLogic( ) { rawTargets = (data as { targets: unknown[] }).targets; } else { - return { - content: [ - { - type: 'text', - text: `Unexpected coverage data format.\n\nRaw output:\n${result.output}`, - }, - ], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Unexpected coverage data format.\n\nRaw output:\n${result.output}`)); + return; } let targets = rawTargets.filter(isValidCoverageTarget); - // Filter by target name if specified if (target) { const lowerTarget = target.toLowerCase(); targets = targets.filter((t) => t.name.toLowerCase().includes(lowerTarget)); if (targets.length === 0) { - return { - content: [ - { - type: 'text', - text: `No targets found matching "${target}".`, - }, - ], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `No targets found matching "${target}".`)); + return; } } if (targets.length === 0) { - return { - content: [ - { - type: 'text', - text: 'No coverage data found in the xcresult bundle.\n\nMake sure tests were run with coverage enabled.', - }, - ], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'error', + 'No coverage data found in the xcresult bundle.\n\nMake sure tests were run with coverage enabled.', + ), + ); + return; } - // Build human-readable output - let text = 'Code Coverage Report\n'; - text += '====================\n\n'; - - // Calculate overall stats let totalCovered = 0; let totalExecutable = 0; for (const t of targets) { @@ -171,31 +153,30 @@ export async function get_coverage_reportLogic( totalExecutable += t.executableLines; } const overallPct = totalExecutable > 0 ? (totalCovered / totalExecutable) * 100 : 0; - text += `Overall: ${overallPct.toFixed(1)}% (${totalCovered}/${totalExecutable} lines)\n\n`; - text += 'Targets:\n'; - // Sort by coverage ascending (lowest coverage first) targets.sort((a, b) => a.lineCoverage - b.lineCoverage); + const targetLines: string[] = []; for (const t of targets) { const pct = (t.lineCoverage * 100).toFixed(1); - text += ` ${t.name}: ${pct}% (${t.coveredLines}/${t.executableLines} lines)\n`; + targetLines.push(`${t.name}: ${pct}% (${t.coveredLines}/${t.executableLines} lines)`); if (showFiles && t.files && t.files.length > 0) { const sortedFiles = [...t.files].sort((a, b) => a.lineCoverage - b.lineCoverage); for (const f of sortedFiles) { const fPct = (f.lineCoverage * 100).toFixed(1); - text += ` ${f.name}: ${fPct}% (${f.coveredLines}/${f.executableLines} lines)\n`; + targetLines.push(` ${f.name}: ${fPct}% (${f.coveredLines}/${f.executableLines} lines)`); } - text += '\n'; } } - return { - content: [{ type: 'text', text }], - nextStepParams: { - get_file_coverage: { xcresultPath }, - }, + ctx.emit(headerEvent); + ctx.emit( + statusLine('info', `Overall: ${overallPct.toFixed(1)}% (${totalCovered}/${totalExecutable} lines)`), + ); + ctx.emit(section('Targets', targetLines)); + ctx.nextStepParams = { + get_file_coverage: { xcresultPath }, }; } diff --git a/src/mcp/tools/coverage/get_file_coverage.ts b/src/mcp/tools/coverage/get_file_coverage.ts index 87d7a519..c9e56986 100644 --- a/src/mcp/tools/coverage/get_file_coverage.ts +++ b/src/mcp/tools/coverage/get_file_coverage.ts @@ -6,12 +6,15 @@ */ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { validateFileExists } from '../../../utils/validation/index.ts'; +import { validateFileExists } from '../../../utils/validation.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/execution/index.ts'; -import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; +import { header, statusLine, section, fileRef } from '../../../utils/tool-event-builders.ts'; const getFileCoverageSchema = z.object({ xcresultPath: z.string().describe('Path to the .xcresult bundle'), @@ -72,54 +75,47 @@ type GetFileCoverageContext = { export async function get_file_coverageLogic( params: GetFileCoverageParams, context: GetFileCoverageContext, -): Promise { +): Promise { + const ctx = getHandlerContext(); const { xcresultPath, file, showLines } = params; + const headerEvent = header('File Coverage', [ + { label: 'xcresult', value: xcresultPath }, + { label: 'File', value: file }, + ]); + const fileExistsValidation = validateFileExists(xcresultPath, context.fileSystem); if (!fileExistsValidation.isValid) { - return { content: [{ type: 'text', text: fileExistsValidation.errorMessage! }], isError: true }; + ctx.emit(headerEvent); + ctx.emit(statusLine('error', fileExistsValidation.errorMessage!)); + return; } log('info', `Getting file coverage for "${file}" from: ${xcresultPath}`); - // Get function-level coverage const funcResult = await context.executor( ['xcrun', 'xccov', 'view', '--report', '--functions-for-file', file, '--json', xcresultPath], 'Get File Function Coverage', false, - undefined, ); if (!funcResult.success) { - return { - content: [ - { - type: 'text', - text: `Failed to get file coverage: ${funcResult.error ?? funcResult.output}\n\nMake sure the xcresult bundle exists and contains coverage data for "${file}".`, - }, - ], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to get file coverage: ${funcResult.error ?? funcResult.output}`)); + return; } let data: unknown; try { data = JSON.parse(funcResult.output); } catch { - return { - content: [ - { - type: 'text', - text: `Failed to parse coverage JSON output.\n\nRaw output:\n${funcResult.output}`, - }, - ], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit( + statusLine('error', `Failed to parse coverage JSON output.\n\nRaw output:\n${funcResult.output}`), + ); + return; } - // The output can be: - // - An array of { file, functions } objects (xccov flat format) - // - { targets: [{ files: [...] }] } (nested format) let fileEntries: FileFunctionCoverage[] = []; if (Array.isArray(data)) { @@ -141,84 +137,106 @@ export async function get_file_coverageLogic( } if (fileEntries.length === 0) { - return { - content: [ - { - type: 'text', - text: `No coverage data found for "${file}".\n\nMake sure the file name or path is correct and that tests covered this file.`, - }, - ], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'error', + `No coverage data found for "${file}".\n\nMake sure the file name or path is correct and that tests covered this file.`, + ), + ); + return; } - // Build human-readable output - let text = ''; + ctx.emit(headerEvent); for (const entry of fileEntries) { const filePct = (entry.lineCoverage * 100).toFixed(1); - text += `File: ${entry.filePath}\n`; - text += `Coverage: ${filePct}% (${entry.coveredLines}/${entry.executableLines} lines)\n`; - text += '---\n'; + ctx.emit(fileRef(entry.filePath, 'File')); + ctx.emit( + statusLine( + 'info', + `Coverage: ${filePct}% (${entry.coveredLines}/${entry.executableLines} lines)`, + ), + ); if (entry.functions && entry.functions.length > 0) { - // Sort functions by line number - const sortedFuncs = [...entry.functions].sort((a, b) => a.lineNumber - b.lineNumber); - - text += 'Functions:\n'; - for (const fn of sortedFuncs) { - const fnPct = (fn.lineCoverage * 100).toFixed(1); - const marker = fn.coveredLines === 0 ? '[NOT COVERED] ' : ''; - text += ` ${marker}L${fn.lineNumber} ${fn.name}: ${fnPct}% (${fn.coveredLines}/${fn.executableLines} lines, called ${fn.executionCount}x)\n`; + const notCovered = entry.functions + .filter((fn) => fn.coveredLines === 0) + .sort((a, b) => b.executableLines - a.executableLines || a.lineNumber - b.lineNumber); + + const partial = entry.functions + .filter((fn) => fn.coveredLines > 0 && fn.coveredLines < fn.executableLines) + .sort((a, b) => a.lineCoverage - b.lineCoverage || a.lineNumber - b.lineNumber); + + const full = entry.functions.filter( + (fn) => fn.executableLines > 0 && fn.coveredLines === fn.executableLines, + ); + + if (notCovered.length > 0) { + const totalMissedLines = notCovered.reduce((sum, fn) => sum + fn.executableLines, 0); + const notCoveredLines = notCovered.map( + (fn) => `L${fn.lineNumber} ${fn.name} -- 0/${fn.executableLines} lines`, + ); + ctx.emit( + section( + `Not Covered (${notCovered.length} ${notCovered.length === 1 ? 'function' : 'functions'}, ${totalMissedLines} lines)`, + notCoveredLines, + { icon: 'red-circle' }, + ), + ); } - // Summary of uncovered functions - const uncoveredFuncs = sortedFuncs.filter((fn) => fn.coveredLines === 0); - if (uncoveredFuncs.length > 0) { - text += `\nUncovered functions (${uncoveredFuncs.length}):\n`; - for (const fn of uncoveredFuncs) { - text += ` - ${fn.name} (line ${fn.lineNumber})\n`; - } + if (partial.length > 0) { + const partialLines = partial.map((fn) => { + const fnPct = (fn.lineCoverage * 100).toFixed(1); + return `L${fn.lineNumber} ${fn.name} -- ${fnPct}% (${fn.coveredLines}/${fn.executableLines} lines)`; + }); + ctx.emit( + section( + `Partial Coverage (${partial.length} ${partial.length === 1 ? 'function' : 'functions'})`, + partialLines, + { icon: 'yellow-circle' }, + ), + ); } - } - text += '\n'; + if (full.length > 0) { + ctx.emit( + section( + `Full Coverage (${full.length} ${full.length === 1 ? 'function' : 'functions'}) -- all at 100%`, + [], + { icon: 'green-circle' }, + ), + ); + } + } } - // Optionally get line-by-line coverage from the archive if (showLines) { const filePath = fileEntries[0].filePath !== 'unknown' ? fileEntries[0].filePath : file; const archiveResult = await context.executor( ['xcrun', 'xccov', 'view', '--archive', '--file', filePath, xcresultPath], 'Get File Line Coverage', false, - undefined, ); if (archiveResult.success && archiveResult.output) { const uncoveredRanges = parseUncoveredLines(archiveResult.output); if (uncoveredRanges.length > 0) { - text += `Uncovered line ranges (${filePath}):\n`; - for (const range of uncoveredRanges) { - if (range.start === range.end) { - text += ` L${range.start}\n`; - } else { - text += ` L${range.start}-${range.end}\n`; - } - } + const rangeLines = uncoveredRanges.map((range) => + range.start === range.end ? `L${range.start}` : `L${range.start}-${range.end}`, + ); + ctx.emit(section(`Uncovered line ranges (${filePath})`, rangeLines)); } else { - text += 'All executable lines are covered.\n'; + ctx.emit(statusLine('success', 'All executable lines are covered.')); } } else { - text += `Note: Could not retrieve line-level coverage from archive.\n`; + ctx.emit(statusLine('warning', 'Could not retrieve line-level coverage from archive.')); } } - return { - content: [{ type: 'text', text: text.trimEnd() }], - nextStepParams: { - get_coverage_report: { xcresultPath }, - }, + ctx.nextStepParams = { + get_coverage_report: { xcresultPath }, }; } diff --git a/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts b/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts index 4559e787..e2e48dcb 100644 --- a/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts +++ b/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { DebuggerManager } from '../../../../utils/debugger/index.ts'; -import type { DebuggerToolContext } from '../../../../utils/debugger/index.ts'; +import { DebuggerManager, type DebuggerToolContext } from '../../../../utils/debugger/index.ts'; import type { DebuggerBackend } from '../../../../utils/debugger/backends/DebuggerBackend.ts'; import type { BreakpointSpec, DebugSessionInfo } from '../../../../utils/debugger/types.ts'; @@ -46,6 +45,8 @@ import { handler as variablesHandler, debug_variablesLogic, } from '../debug_variables.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + function createMockBackend(overrides: Partial = {}): DebuggerBackend { return { @@ -130,39 +131,43 @@ describe('debug_attach_sim', () => { it('should attach successfully with pid', async () => { const ctx = createTestContext(); - const result = await debug_attach_simLogic( - { - simulatorId: 'test-sim-uuid', - pid: 1234, - continueOnAttach: true, - makeCurrent: true, - }, - ctx, + const result = await runLogic(() => + debug_attach_simLogic( + { + simulatorId: 'test-sim-uuid', + pid: 1234, + continueOnAttach: true, + makeCurrent: true, + }, + ctx, + ), ); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = allText(result); expect(text).toContain('Attached'); expect(text).toContain('1234'); expect(text).toContain('test-sim-uuid'); - expect(text).toContain('Debug session ID:'); + expect(text).toContain('Debug session ID'); }); it('should attach without continuing when continueOnAttach is false', async () => { const ctx = createTestContext(); - const result = await debug_attach_simLogic( - { - simulatorId: 'test-sim-uuid', - pid: 1234, - continueOnAttach: false, - makeCurrent: true, - }, - ctx, + const result = await runLogic(() => + debug_attach_simLogic( + { + simulatorId: 'test-sim-uuid', + pid: 1234, + continueOnAttach: false, + makeCurrent: true, + }, + ctx, + ), ); - expect(result.isError).toBe(false); - const text = result.content[0].text; + expect(result.isError).toBeFalsy(); + const text = allText(result); expect(text).toContain('Execution is paused'); }); @@ -173,18 +178,20 @@ describe('debug_attach_sim', () => { }, }); - const result = await debug_attach_simLogic( - { - simulatorId: 'test-sim-uuid', - pid: 1234, - continueOnAttach: true, - makeCurrent: true, - }, - ctx, + const result = await runLogic(() => + debug_attach_simLogic( + { + simulatorId: 'test-sim-uuid', + pid: 1234, + continueOnAttach: true, + makeCurrent: true, + }, + ctx, + ), ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = allText(result); expect(text).toContain('Failed to attach debugger'); expect(text).toContain('LLDB attach failed'); }); @@ -196,18 +203,20 @@ describe('debug_attach_sim', () => { }, }); - const result = await debug_attach_simLogic( - { - simulatorId: 'test-sim-uuid', - pid: 1234, - continueOnAttach: true, - makeCurrent: true, - }, - ctx, + const result = await runLogic(() => + debug_attach_simLogic( + { + simulatorId: 'test-sim-uuid', + pid: 1234, + continueOnAttach: true, + makeCurrent: true, + }, + ctx, + ), ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = allText(result); expect(text).toContain('Failed to resume debugger after attach'); }); @@ -220,14 +229,16 @@ describe('debug_attach_sim', () => { debugger: createTestDebuggerManager(), }; - const result = await debug_attach_simLogic( - { - simulatorName: 'NonExistent Simulator', - bundleId: 'com.test.app', - continueOnAttach: true, - makeCurrent: true, - }, - ctx, + const result = await runLogic(() => + debug_attach_simLogic( + { + simulatorName: 'NonExistent Simulator', + bundleId: 'com.test.app', + continueOnAttach: true, + makeCurrent: true, + }, + ctx, + ), ); expect(result.isError).toBe(true); @@ -242,32 +253,36 @@ describe('debug_attach_sim', () => { debugger: createTestDebuggerManager(), }; - const result = await debug_attach_simLogic( - { - simulatorId: 'test-sim-uuid', - bundleId: 'com.test.app', - continueOnAttach: true, - makeCurrent: true, - }, - ctx, + const result = await runLogic(() => + debug_attach_simLogic( + { + simulatorId: 'test-sim-uuid', + bundleId: 'com.test.app', + continueOnAttach: true, + makeCurrent: true, + }, + ctx, + ), ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = allText(result); expect(text).toContain('Failed to resolve simulator PID'); }); it('should include nextStepParams on success', async () => { const ctx = createTestContext(); - const result = await debug_attach_simLogic( - { - simulatorId: 'test-sim-uuid', - pid: 1234, - continueOnAttach: true, - makeCurrent: true, - }, - ctx, + const result = await runLogic(() => + debug_attach_simLogic( + { + simulatorId: 'test-sim-uuid', + pid: 1234, + continueOnAttach: true, + makeCurrent: true, + }, + ctx, + ), ); expect(result.nextStepParams).toBeDefined(); @@ -315,62 +330,46 @@ describe('debug_breakpoint_add', () => { }); }); - describe('Handler Requirements', () => { - it('should handle missing debug session gracefully', async () => { - const ctx = createTestContext(); - - const result = await debug_breakpoint_addLogic({ file: 'main.swift', line: 10 }, ctx); - - expect(result.isError).toBe(true); - const text = result.content[0].text; - expect(text).toContain('No active debug session'); - }); - }); - describe('Logic Behavior', () => { it('should add file-line breakpoint successfully', async () => { const { ctx, session } = await createSessionAndContext(); - const result = await debug_breakpoint_addLogic( - { debugSessionId: session.id, file: 'main.swift', line: 42 }, - ctx, + const result = await runLogic(() => + debug_breakpoint_addLogic( + { debugSessionId: session.id, file: 'main.swift', line: 42 }, + ctx, + ), ); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toContain('Breakpoint'); - expect(text).toContain('set'); + expect(result.isError).toBeFalsy(); }); it('should add function breakpoint successfully', async () => { const { ctx, session } = await createSessionAndContext(); - const result = await debug_breakpoint_addLogic( - { debugSessionId: session.id, function: 'viewDidLoad' }, - ctx, + const result = await runLogic(() => + debug_breakpoint_addLogic({ debugSessionId: session.id, function: 'viewDidLoad' }, ctx), ); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toContain('Breakpoint'); + expect(result.isError).toBeFalsy(); }); it('should add breakpoint with condition', async () => { const { ctx, session } = await createSessionAndContext(); - const result = await debug_breakpoint_addLogic( - { - debugSessionId: session.id, - file: 'main.swift', - line: 10, - condition: 'x > 5', - }, - ctx, + const result = await runLogic(() => + debug_breakpoint_addLogic( + { + debugSessionId: session.id, + file: 'main.swift', + line: 10, + condition: 'x > 5', + }, + ctx, + ), ); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toContain('Breakpoint'); + expect(result.isError).toBeFalsy(); }); it('should return error when addBreakpoint throws', async () => { @@ -380,13 +379,15 @@ describe('debug_breakpoint_add', () => { }, }); - const result = await debug_breakpoint_addLogic( - { debugSessionId: session.id, file: 'missing.swift', line: 1 }, - ctx, + const result = await runLogic(() => + debug_breakpoint_addLogic( + { debugSessionId: session.id, file: 'missing.swift', line: 1 }, + ctx, + ), ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = allText(result); expect(text).toContain('Failed to add breakpoint'); expect(text).toContain('Invalid file path'); }); @@ -394,11 +395,11 @@ describe('debug_breakpoint_add', () => { it('should use current session when debugSessionId is omitted', async () => { const { ctx } = await createSessionAndContext(); - const result = await debug_breakpoint_addLogic({ file: 'main.swift', line: 10 }, ctx); + const result = await runLogic(() => + debug_breakpoint_addLogic({ file: 'main.swift', line: 10 }, ctx), + ); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toContain('Breakpoint'); + expect(result.isError).toBeFalsy(); }); }); }); @@ -423,30 +424,15 @@ describe('debug_breakpoint_remove', () => { }); }); - describe('Handler Requirements', () => { - it('should handle missing debug session gracefully', async () => { - const ctx = createTestContext(); - - const result = await debug_breakpoint_removeLogic({ breakpointId: 1 }, ctx); - - expect(result.isError).toBe(true); - const text = result.content[0].text; - expect(text).toContain('No active debug session'); - }); - }); - describe('Logic Behavior', () => { it('should remove breakpoint successfully', async () => { const { ctx, session } = await createSessionAndContext(); - const result = await debug_breakpoint_removeLogic( - { debugSessionId: session.id, breakpointId: 1 }, - ctx, + const result = await runLogic(() => + debug_breakpoint_removeLogic({ debugSessionId: session.id, breakpointId: 1 }, ctx), ); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toContain('Breakpoint 1 removed'); + expect(result.isError).toBeFalsy(); }); it('should return error when removeBreakpoint throws', async () => { @@ -456,13 +442,12 @@ describe('debug_breakpoint_remove', () => { }, }); - const result = await debug_breakpoint_removeLogic( - { debugSessionId: session.id, breakpointId: 999 }, - ctx, + const result = await runLogic(() => + debug_breakpoint_removeLogic({ debugSessionId: session.id, breakpointId: 999 }, ctx), ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = allText(result); expect(text).toContain('Failed to remove breakpoint'); expect(text).toContain('Breakpoint not found'); }); @@ -470,11 +455,9 @@ describe('debug_breakpoint_remove', () => { it('should use current session when debugSessionId is omitted', async () => { const { ctx } = await createSessionAndContext(); - const result = await debug_breakpoint_removeLogic({ breakpointId: 1 }, ctx); + const result = await runLogic(() => debug_breakpoint_removeLogic({ breakpointId: 1 }, ctx)); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toContain('Breakpoint 1 removed'); + expect(result.isError).toBeFalsy(); }); }); }); @@ -498,38 +481,21 @@ describe('debug_continue', () => { }); }); - describe('Handler Requirements', () => { - it('should handle missing debug session gracefully', async () => { - const ctx = createTestContext(); - - const result = await debug_continueLogic({}, ctx); - - expect(result.isError).toBe(true); - const text = result.content[0].text; - expect(text).toContain('No active debug session'); - }); - }); - describe('Logic Behavior', () => { it('should resume session successfully with explicit id', async () => { const { ctx, session } = await createSessionAndContext(); - const result = await debug_continueLogic({ debugSessionId: session.id }, ctx); + const result = await runLogic(() => debug_continueLogic({ debugSessionId: session.id }, ctx)); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toContain('Resumed debugger session'); - expect(text).toContain(session.id); + expect(result.isError).toBeFalsy(); }); it('should resume current session when debugSessionId is omitted', async () => { const { ctx } = await createSessionAndContext(); - const result = await debug_continueLogic({}, ctx); + const result = await runLogic(() => debug_continueLogic({}, ctx)); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toContain('Resumed debugger session'); + expect(result.isError).toBeFalsy(); }); it('should return error when resume throws', async () => { @@ -539,10 +505,10 @@ describe('debug_continue', () => { }, }); - const result = await debug_continueLogic({ debugSessionId: session.id }, ctx); + const result = await runLogic(() => debug_continueLogic({ debugSessionId: session.id }, ctx)); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = allText(result); expect(text).toContain('Failed to resume debugger'); expect(text).toContain('Process terminated'); }); @@ -568,38 +534,21 @@ describe('debug_detach', () => { }); }); - describe('Handler Requirements', () => { - it('should handle missing debug session gracefully', async () => { - const ctx = createTestContext(); - - const result = await debug_detachLogic({}, ctx); - - expect(result.isError).toBe(true); - const text = result.content[0].text; - expect(text).toContain('No active debug session'); - }); - }); - describe('Logic Behavior', () => { it('should detach session successfully with explicit id', async () => { const { ctx, session } = await createSessionAndContext(); - const result = await debug_detachLogic({ debugSessionId: session.id }, ctx); + const result = await runLogic(() => debug_detachLogic({ debugSessionId: session.id }, ctx)); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toContain('Detached debugger session'); - expect(text).toContain(session.id); + expect(result.isError).toBeFalsy(); }); it('should detach current session when debugSessionId is omitted', async () => { const { ctx } = await createSessionAndContext(); - const result = await debug_detachLogic({}, ctx); + const result = await runLogic(() => debug_detachLogic({}, ctx)); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toContain('Detached debugger session'); + expect(result.isError).toBeFalsy(); }); it('should return error when detach throws', async () => { @@ -609,10 +558,10 @@ describe('debug_detach', () => { }, }); - const result = await debug_detachLogic({ debugSessionId: session.id }, ctx); + const result = await runLogic(() => debug_detachLogic({ debugSessionId: session.id }, ctx)); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = allText(result); expect(text).toContain('Failed to detach debugger'); expect(text).toContain('Connection lost'); }); @@ -640,32 +589,17 @@ describe('debug_lldb_command', () => { }); }); - describe('Handler Requirements', () => { - it('should handle missing debug session gracefully', async () => { - const ctx = createTestContext(); - - const result = await debug_lldb_commandLogic({ command: 'bt' }, ctx); - - expect(result.isError).toBe(true); - const text = result.content[0].text; - expect(text).toContain('No active debug session'); - }); - }); - describe('Logic Behavior', () => { it('should run command successfully', async () => { const { ctx, session } = await createSessionAndContext({ runCommand: async () => ' frame #0: main\n', }); - const result = await debug_lldb_commandLogic( - { debugSessionId: session.id, command: 'bt' }, - ctx, + const result = await runLogic(() => + debug_lldb_commandLogic({ debugSessionId: session.id, command: 'bt' }, ctx), ); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toBe('frame #0: main'); + expect(result.isError).toBeFalsy(); }); it('should pass timeoutMs through to runCommand', async () => { @@ -677,9 +611,11 @@ describe('debug_lldb_command', () => { }, }); - await debug_lldb_commandLogic( - { debugSessionId: session.id, command: 'expr x', timeoutMs: 5000 }, - ctx, + await runLogic(() => + debug_lldb_commandLogic( + { debugSessionId: session.id, command: 'expr x', timeoutMs: 5000 }, + ctx, + ), ); expect(receivedOpts?.timeoutMs).toBe(5000); @@ -692,13 +628,12 @@ describe('debug_lldb_command', () => { }, }); - const result = await debug_lldb_commandLogic( - { debugSessionId: session.id, command: 'expr longRunning()' }, - ctx, + const result = await runLogic(() => + debug_lldb_commandLogic({ debugSessionId: session.id, command: 'expr longRunning()' }, ctx), ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = allText(result); expect(text).toContain('Failed to run LLDB command'); expect(text).toContain('Command timed out'); }); @@ -708,11 +643,9 @@ describe('debug_lldb_command', () => { runCommand: async () => 'result', }); - const result = await debug_lldb_commandLogic({ command: 'po self' }, ctx); + const result = await runLogic(() => debug_lldb_commandLogic({ command: 'po self' }, ctx)); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toBe('result'); + expect(result.isError).toBeFalsy(); }); }); }); @@ -738,18 +671,6 @@ describe('debug_stack', () => { }); }); - describe('Handler Requirements', () => { - it('should handle missing debug session gracefully', async () => { - const ctx = createTestContext(); - - const result = await debug_stackLogic({}, ctx); - - expect(result.isError).toBe(true); - const text = result.content[0].text; - expect(text).toContain('No active debug session'); - }); - }); - describe('Logic Behavior', () => { it('should return stack output successfully', async () => { const stackOutput = ' frame #0: 0x0000 main at main.swift:10\n frame #1: 0x0001 start\n'; @@ -757,11 +678,9 @@ describe('debug_stack', () => { getStack: async () => stackOutput, }); - const result = await debug_stackLogic({ debugSessionId: session.id }, ctx); + const result = await runLogic(() => debug_stackLogic({ debugSessionId: session.id }, ctx)); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toBe(stackOutput.trim()); + expect(result.isError).toBeFalsy(); }); it('should pass threadIndex and maxFrames through', async () => { @@ -773,7 +692,9 @@ describe('debug_stack', () => { }, }); - await debug_stackLogic({ debugSessionId: session.id, threadIndex: 2, maxFrames: 5 }, ctx); + await runLogic(() => + debug_stackLogic({ debugSessionId: session.id, threadIndex: 2, maxFrames: 5 }, ctx), + ); expect(receivedOpts?.threadIndex).toBe(2); expect(receivedOpts?.maxFrames).toBe(5); @@ -786,10 +707,10 @@ describe('debug_stack', () => { }, }); - const result = await debug_stackLogic({ debugSessionId: session.id }, ctx); + const result = await runLogic(() => debug_stackLogic({ debugSessionId: session.id }, ctx)); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = allText(result); expect(text).toContain('Failed to get stack'); expect(text).toContain('Process not stopped'); }); @@ -799,10 +720,9 @@ describe('debug_stack', () => { getStack: async () => 'frame #0: main', }); - const result = await debug_stackLogic({}, ctx); + const result = await runLogic(() => debug_stackLogic({}, ctx)); - expect(result.isError).toBe(false); - expect(result.content[0].text).toBe('frame #0: main'); + expect(result.isError).toBeFalsy(); }); }); }); @@ -827,18 +747,6 @@ describe('debug_variables', () => { }); }); - describe('Handler Requirements', () => { - it('should handle missing debug session gracefully', async () => { - const ctx = createTestContext(); - - const result = await debug_variablesLogic({}, ctx); - - expect(result.isError).toBe(true); - const text = result.content[0].text; - expect(text).toContain('No active debug session'); - }); - }); - describe('Logic Behavior', () => { it('should return variables output successfully', async () => { const variablesOutput = ' (Int) x = 42\n (String) name = "hello"\n'; @@ -846,11 +754,11 @@ describe('debug_variables', () => { getVariables: async () => variablesOutput, }); - const result = await debug_variablesLogic({ debugSessionId: session.id }, ctx); + const result = await runLogic(() => + debug_variablesLogic({ debugSessionId: session.id }, ctx), + ); - expect(result.isError).toBe(false); - const text = result.content[0].text; - expect(text).toBe(variablesOutput.trim()); + expect(result.isError).toBeFalsy(); }); it('should pass frameIndex through', async () => { @@ -862,7 +770,9 @@ describe('debug_variables', () => { }, }); - await debug_variablesLogic({ debugSessionId: session.id, frameIndex: 3 }, ctx); + await runLogic(() => + debug_variablesLogic({ debugSessionId: session.id, frameIndex: 3 }, ctx), + ); expect(receivedOpts?.frameIndex).toBe(3); }); @@ -874,13 +784,12 @@ describe('debug_variables', () => { }, }); - const result = await debug_variablesLogic( - { debugSessionId: session.id, frameIndex: 999 }, - ctx, + const result = await runLogic(() => + debug_variablesLogic({ debugSessionId: session.id, frameIndex: 999 }, ctx), ); expect(result.isError).toBe(true); - const text = result.content[0].text; + const text = allText(result); expect(text).toContain('Failed to get variables'); expect(text).toContain('Frame index out of range'); }); @@ -890,10 +799,9 @@ describe('debug_variables', () => { getVariables: async () => 'y = 99', }); - const result = await debug_variablesLogic({}, ctx); + const result = await runLogic(() => debug_variablesLogic({}, ctx)); - expect(result.isError).toBe(false); - expect(result.content[0].text).toBe('y = 99'); + expect(result.isError).toBeFalsy(); }); }); }); diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts index 66292f28..bf8385ca 100644 --- a/src/mcp/tools/debugging/debug_attach_sim.ts +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -1,12 +1,13 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, detailTree, section } from '../../../utils/tool-event-builders.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; import { createSessionAwareToolWithContext, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, @@ -60,8 +61,10 @@ export type DebugAttachSimParams = z.infer; export async function debug_attach_simLogic( params: DebugAttachSimParams, ctx: DebuggerToolContext, -): Promise { +): Promise { const { executor, debugger: debuggerManager } = ctx; + const headerEvent = header('Attach Debugger'); + const handlerCtx = getHandlerContext(); const simResult = await determineSimulatorUuid( { simulatorId: params.simulatorId, simulatorName: params.simulatorName }, @@ -69,12 +72,18 @@ export async function debug_attach_simLogic( ); if (simResult.error) { - return simResult.error; + handlerCtx.emit(headerEvent); + handlerCtx.emit(statusLine('error', simResult.error)); + return; } const simulatorId = simResult.uuid; if (!simulatorId) { - return createErrorResponse('Simulator resolution failed', 'Unable to determine simulator UUID'); + handlerCtx.emit(headerEvent); + handlerCtx.emit( + statusLine('error', 'Simulator resolution failed: Unable to determine simulator UUID'), + ); + return; } let pid = params.pid; @@ -87,76 +96,123 @@ export async function debug_attach_simLogic( }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to resolve simulator PID', message); + handlerCtx.emit(headerEvent); + handlerCtx.emit(statusLine('error', `Failed to resolve simulator PID: ${message}`)); + return; } } if (!pid) { - return createErrorResponse('Missing PID', 'Unable to resolve process ID to attach'); + handlerCtx.emit(headerEvent); + handlerCtx.emit(statusLine('error', 'Missing PID: Unable to resolve process ID to attach')); + return; } - try { - const session = await debuggerManager.createSession({ - simulatorId, - pid, - waitFor: params.waitFor, - }); + return withErrorHandling( + handlerCtx, + async () => { + const session = await debuggerManager.createSession({ + simulatorId, + pid, + waitFor: params.waitFor, + }); - const isCurrent = params.makeCurrent ?? true; - if (isCurrent) { - debuggerManager.setCurrentSession(session.id); - } + const isCurrent = params.makeCurrent ?? true; + if (isCurrent) { + debuggerManager.setCurrentSession(session.id); + } - const shouldContinue = params.continueOnAttach ?? true; - if (shouldContinue) { - try { - await debuggerManager.resumeSession(session.id); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const shouldContinue = params.continueOnAttach ?? true; + if (shouldContinue) { + try { + await debuggerManager.resumeSession(session.id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/not\s*stopped/i.test(message)) { + log('debug', 'Process already running after attach, no resume needed'); + } else { + try { + await debuggerManager.detachSession(session.id); + } catch (detachError) { + const detachMessage = + detachError instanceof Error ? detachError.message : String(detachError); + log( + 'warn', + `Failed to detach debugger session after resume failure: ${detachMessage}`, + ); + } + handlerCtx.emit(headerEvent); + handlerCtx.emit( + statusLine('error', `Failed to resume debugger after attach: ${message}`), + ); + return; + } + } + } else { try { - await debuggerManager.detachSession(session.id); - } catch (detachError) { - const detachMessage = - detachError instanceof Error ? detachError.message : String(detachError); - log('warn', `Failed to detach debugger session after resume failure: ${detachMessage}`); + await debuggerManager.runCommand(session.id, 'process interrupt'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/already stopped|not running/i.test(message)) { + try { + await debuggerManager.detachSession(session.id); + } catch (detachError) { + const detachMessage = + detachError instanceof Error ? detachError.message : String(detachError); + log( + 'warn', + `Failed to detach debugger session after pause failure: ${detachMessage}`, + ); + } + handlerCtx.emit(headerEvent); + handlerCtx.emit( + statusLine('error', `Failed to pause debugger after attach: ${message}`), + ); + return; + } } - return createErrorResponse('Failed to resume debugger after attach', message); } - } - const warningText = simResult.warning ? `⚠️ ${simResult.warning}\n\n` : ''; - const currentText = isCurrent - ? 'This session is now the current debug session.' - : 'This session is not set as the current session.'; - const resumeText = shouldContinue - ? 'Execution resumed after attach.' - : 'Execution is paused. Use debug_continue to resume before UI automation.'; - - const backendLabel = session.backend === 'dap' ? 'DAP debugger' : 'LLDB'; - - return { - content: [ - { - type: 'text', - text: - `${warningText}✅ Attached ${backendLabel} to simulator process ${pid} (${simulatorId}).\n\n` + - `Debug session ID: ${session.id}\n` + - `${currentText}\n` + - `${resumeText}`, - }, - ], - nextStepParams: { + const backendLabel = session.backend === 'dap' ? 'DAP debugger' : 'LLDB'; + const currentText = isCurrent + ? 'This session is now the current debug session.' + : 'This session is not set as the current session.'; + + const execState = await debuggerManager.getExecutionState(session.id); + const isRunning = execState.status === 'running' || execState.status === 'unknown'; + const resumeText = isRunning + ? 'Execution is running. App is responsive to UI interaction.' + : 'Execution is paused. Use debug_continue to resume before UI automation.'; + + handlerCtx.emit(headerEvent); + if (simResult.warning) { + handlerCtx.emit(section('Warning', [simResult.warning])); + } + handlerCtx.emit( + statusLine( + 'success', + `Attached ${backendLabel} to simulator process ${pid} (${simulatorId})`, + ), + ); + handlerCtx.emit( + detailTree([ + { label: 'Debug session ID', value: session.id }, + { label: 'Status', value: currentText }, + { label: 'Execution', value: resumeText }, + ]), + ); + handlerCtx.nextStepParams = { debug_breakpoint_add: { debugSessionId: session.id, file: '...', line: 123 }, debug_continue: { debugSessionId: session.id }, debug_stack: { debugSessionId: session.id }, - }, - isError: false, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log('error', `Failed to attach LLDB: ${message}`); - return createErrorResponse('Failed to attach debugger', message); - } + }; + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to attach debugger: ${message}`, + logMessage: ({ message }) => `Failed to attach LLDB: ${message}`, + }, + ); } const publicSchemaObject = z.strictObject( diff --git a/src/mcp/tools/debugging/debug_breakpoint_add.ts b/src/mcp/tools/debugging/debug_breakpoint_add.ts index fe6d17c5..516f1b14 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_add.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_add.ts @@ -1,8 +1,11 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, type DebuggerToolContext, @@ -36,21 +39,35 @@ export type DebugBreakpointAddParams = z.infer; export async function debug_breakpoint_addLogic( params: DebugBreakpointAddParams, ctx: DebuggerToolContext, -): Promise { - try { - const spec: BreakpointSpec = params.function - ? { kind: 'function', name: params.function } - : { kind: 'file-line', file: params.file!, line: params.line! }; - - const result = await ctx.debugger.addBreakpoint(params.debugSessionId, spec, { - condition: params.condition, - }); - - return createTextResponse(`✅ Breakpoint ${result.id} set.\n\n${result.rawOutput.trim()}`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to add breakpoint', message); - } +): Promise { + const headerEvent = header('Add Breakpoint'); + + const handlerCtx = getHandlerContext(); + + return withErrorHandling( + handlerCtx, + async () => { + const spec: BreakpointSpec = params.function + ? { kind: 'function', name: params.function } + : { kind: 'file-line', file: params.file!, line: params.line! }; + + const result = await ctx.debugger.addBreakpoint(params.debugSessionId, spec, { + condition: params.condition, + }); + + const rawOutput = result.rawOutput.trim(); + + handlerCtx.emit(headerEvent); + handlerCtx.emit(statusLine('success', `Breakpoint ${result.id} set`)); + if (rawOutput) { + handlerCtx.emit(section('Output:', rawOutput.split('\n'))); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to add breakpoint: ${message}`, + }, + ); } export const schema = baseSchemaObject.shape; diff --git a/src/mcp/tools/debugging/debug_breakpoint_remove.ts b/src/mcp/tools/debugging/debug_breakpoint_remove.ts index 53e7b95d..a606ff69 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_remove.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_remove.ts @@ -1,7 +1,10 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; -import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, type DebuggerToolContext, @@ -17,14 +20,31 @@ export type DebugBreakpointRemoveParams = z.infer { - try { - const output = await ctx.debugger.removeBreakpoint(params.debugSessionId, params.breakpointId); - return createTextResponse(`✅ Breakpoint ${params.breakpointId} removed.\n\n${output.trim()}`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to remove breakpoint', message); - } +): Promise { + const headerEvent = header('Remove Breakpoint'); + + const handlerCtx = getHandlerContext(); + + return withErrorHandling( + handlerCtx, + async () => { + const output = await ctx.debugger.removeBreakpoint( + params.debugSessionId, + params.breakpointId, + ); + const rawOutput = output.trim(); + + handlerCtx.emit(headerEvent); + handlerCtx.emit(statusLine('success', `Breakpoint ${params.breakpointId} removed`)); + if (rawOutput) { + handlerCtx.emit(section('Output:', rawOutput.split('\n'))); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to remove breakpoint: ${message}`, + }, + ); } export const schema = debugBreakpointRemoveSchema.shape; diff --git a/src/mcp/tools/debugging/debug_continue.ts b/src/mcp/tools/debugging/debug_continue.ts index 4da697cd..36128360 100644 --- a/src/mcp/tools/debugging/debug_continue.ts +++ b/src/mcp/tools/debugging/debug_continue.ts @@ -1,7 +1,10 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; -import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, type DebuggerToolContext, @@ -16,16 +19,27 @@ export type DebugContinueParams = z.infer; export async function debug_continueLogic( params: DebugContinueParams, ctx: DebuggerToolContext, -): Promise { - try { - const targetId = params.debugSessionId ?? ctx.debugger.getCurrentSessionId(); - await ctx.debugger.resumeSession(targetId ?? undefined); - - return createTextResponse(`✅ Resumed debugger session${targetId ? ` ${targetId}` : ''}.`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to resume debugger', message); - } +): Promise { + const headerEvent = header('Continue'); + + const handlerCtx = getHandlerContext(); + + return withErrorHandling( + handlerCtx, + async () => { + const targetId = params.debugSessionId ?? ctx.debugger.getCurrentSessionId(); + await ctx.debugger.resumeSession(targetId ?? undefined); + + handlerCtx.emit(headerEvent); + handlerCtx.emit( + statusLine('success', `Resumed debugger session${targetId ? ` ${targetId}` : ''}`), + ); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to resume debugger: ${message}`, + }, + ); } export const schema = debugContinueSchema.shape; diff --git a/src/mcp/tools/debugging/debug_detach.ts b/src/mcp/tools/debugging/debug_detach.ts index a1bb25ec..c831ea26 100644 --- a/src/mcp/tools/debugging/debug_detach.ts +++ b/src/mcp/tools/debugging/debug_detach.ts @@ -1,7 +1,10 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; -import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, type DebuggerToolContext, @@ -16,16 +19,27 @@ export type DebugDetachParams = z.infer; export async function debug_detachLogic( params: DebugDetachParams, ctx: DebuggerToolContext, -): Promise { - try { - const targetId = params.debugSessionId ?? ctx.debugger.getCurrentSessionId(); - await ctx.debugger.detachSession(targetId ?? undefined); - - return createTextResponse(`✅ Detached debugger session${targetId ? ` ${targetId}` : ''}.`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to detach debugger', message); - } +): Promise { + const headerEvent = header('Detach'); + + const handlerCtx = getHandlerContext(); + + return withErrorHandling( + handlerCtx, + async () => { + const targetId = params.debugSessionId ?? ctx.debugger.getCurrentSessionId(); + await ctx.debugger.detachSession(targetId ?? undefined); + + handlerCtx.emit(headerEvent); + handlerCtx.emit( + statusLine('success', `Detached debugger session${targetId ? ` ${targetId}` : ''}`), + ); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to detach debugger: ${message}`, + }, + ); } export const schema = debugDetachSchema.shape; diff --git a/src/mcp/tools/debugging/debug_lldb_command.ts b/src/mcp/tools/debugging/debug_lldb_command.ts index 7e34475e..1efb6d9d 100644 --- a/src/mcp/tools/debugging/debug_lldb_command.ts +++ b/src/mcp/tools/debugging/debug_lldb_command.ts @@ -1,8 +1,11 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, type DebuggerToolContext, @@ -21,16 +24,30 @@ export type DebugLldbCommandParams = z.infer; export async function debug_lldb_commandLogic( params: DebugLldbCommandParams, ctx: DebuggerToolContext, -): Promise { - try { - const output = await ctx.debugger.runCommand(params.debugSessionId, params.command, { - timeoutMs: params.timeoutMs, - }); - return createTextResponse(output.trim()); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to run LLDB command', message); - } +): Promise { + const headerEvent = header('LLDB Command', [{ label: 'Command', value: params.command }]); + + const handlerCtx = getHandlerContext(); + + return withErrorHandling( + handlerCtx, + async () => { + const output = await ctx.debugger.runCommand(params.debugSessionId, params.command, { + timeoutMs: params.timeoutMs, + }); + const trimmed = output.trim(); + + handlerCtx.emit(headerEvent); + handlerCtx.emit(statusLine('success', 'Command executed')); + if (trimmed) { + handlerCtx.emit(section('Output:', trimmed.split('\n'))); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to run LLDB command: ${message}`, + }, + ); } export const schema = baseSchemaObject.shape; diff --git a/src/mcp/tools/debugging/debug_stack.ts b/src/mcp/tools/debugging/debug_stack.ts index 46f149c6..6f8403a8 100644 --- a/src/mcp/tools/debugging/debug_stack.ts +++ b/src/mcp/tools/debugging/debug_stack.ts @@ -1,7 +1,10 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; -import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, type DebuggerToolContext, @@ -18,17 +21,31 @@ export type DebugStackParams = z.infer; export async function debug_stackLogic( params: DebugStackParams, ctx: DebuggerToolContext, -): Promise { - try { - const output = await ctx.debugger.getStack(params.debugSessionId, { - threadIndex: params.threadIndex, - maxFrames: params.maxFrames, - }); - return createTextResponse(output.trim()); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to get stack', message); - } +): Promise { + const headerEvent = header('Stack Trace'); + + const handlerCtx = getHandlerContext(); + + return withErrorHandling( + handlerCtx, + async () => { + const output = await ctx.debugger.getStack(params.debugSessionId, { + threadIndex: params.threadIndex, + maxFrames: params.maxFrames, + }); + const trimmed = output.trim(); + + handlerCtx.emit(headerEvent); + handlerCtx.emit(statusLine('success', 'Stack trace retrieved')); + if (trimmed) { + handlerCtx.emit(section('Frames:', trimmed.split('\n'))); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to get stack: ${message}`, + }, + ); } export const schema = debugStackSchema.shape; diff --git a/src/mcp/tools/debugging/debug_variables.ts b/src/mcp/tools/debugging/debug_variables.ts index 7946b011..db8f686b 100644 --- a/src/mcp/tools/debugging/debug_variables.ts +++ b/src/mcp/tools/debugging/debug_variables.ts @@ -1,7 +1,10 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; -import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; import { getDefaultDebuggerToolContext, type DebuggerToolContext, @@ -17,16 +20,30 @@ export type DebugVariablesParams = z.infer; export async function debug_variablesLogic( params: DebugVariablesParams, ctx: DebuggerToolContext, -): Promise { - try { - const output = await ctx.debugger.getVariables(params.debugSessionId, { - frameIndex: params.frameIndex, - }); - return createTextResponse(output.trim()); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to get variables', message); - } +): Promise { + const headerEvent = header('Variables'); + + const handlerCtx = getHandlerContext(); + + return withErrorHandling( + handlerCtx, + async () => { + const output = await ctx.debugger.getVariables(params.debugSessionId, { + frameIndex: params.frameIndex, + }); + const trimmed = output.trim(); + + handlerCtx.emit(headerEvent); + handlerCtx.emit(statusLine('success', 'Variables retrieved')); + if (trimmed) { + handlerCtx.emit(section('Values:', trimmed.split('\n'))); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to get variables: ${message}`, + }, + ); } export const schema = debugVariablesSchema.shape; diff --git a/src/mcp/tools/doctor/__tests__/doctor.test.ts b/src/mcp/tools/doctor/__tests__/doctor.test.ts index 5fb17d6e..33ee3852 100644 --- a/src/mcp/tools/doctor/__tests__/doctor.test.ts +++ b/src/mcp/tools/doctor/__tests__/doctor.test.ts @@ -1,13 +1,8 @@ -/** - * Tests for doctor plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, runDoctor, type DoctorDependencies } from '../doctor.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; function createDeps(overrides?: Partial): DoctorDependencies { const base: DoctorDependencies = { @@ -141,15 +136,10 @@ describe('doctor tool', () => { const deps = createDeps(); const result = await runDoctor({}, deps); - expect(result.content).toEqual([ - { - type: 'text', - text: result.content[0].text, - }, - ]); - expect(typeof result.content[0].text).toBe('string'); - expect(result.content[0].text).toContain('### Manifest Tool Inventory'); - expect(result.content[0].text).not.toContain('Total Plugins'); + expect(result.content.length).toBeGreaterThan(0); + const text = allText(result); + expect(text).toContain('Manifest Tool Inventory'); + expect(text).not.toContain('Total Plugins'); }); it('should handle manifest loading failure', async () => { @@ -163,13 +153,9 @@ describe('doctor tool', () => { const result = await runDoctor({}, deps); - expect(result.content).toEqual([ - { - type: 'text', - text: result.content[0].text, - }, - ]); - expect(typeof result.content[0].text).toBe('string'); + expect(result.content.length).toBeGreaterThan(0); + const text = allText(result); + expect(text).toContain('Manifest loading failed'); }); it('should handle xcode command failure', async () => { @@ -182,13 +168,9 @@ describe('doctor tool', () => { }); const result = await runDoctor({}, deps); - expect(result.content).toEqual([ - { - type: 'text', - text: result.content[0].text, - }, - ]); - expect(typeof result.content[0].text).toBe('string'); + expect(result.content.length).toBeGreaterThan(0); + const text = allText(result); + expect(text).toContain('Xcode not found'); }); it('should handle xcodemake check failure', async () => { @@ -209,13 +191,9 @@ describe('doctor tool', () => { }); const result = await runDoctor({}, deps); - expect(result.content).toEqual([ - { - type: 'text', - text: result.content[0].text, - }, - ]); - expect(typeof result.content[0].text).toBe('string'); + expect(result.content.length).toBeGreaterThan(0); + const text = allText(result); + expect(text).toContain('xcodemake: Not found'); }); it('should redact path and sensitive values in output', async () => { @@ -255,8 +233,7 @@ describe('doctor tool', () => { }); const result = await runDoctor({}, deps); - const text = result.content[0].text; - if (typeof text !== 'string') throw new Error('Unexpected doctor output type'); + const text = allText(result); expect(text).toContain(''); expect(text).not.toContain('testuser'); @@ -302,10 +279,9 @@ describe('doctor tool', () => { }); const result = await runDoctor({ nonRedacted: true }, deps); - const text = result.content[0].text; - if (typeof text !== 'string') throw new Error('Unexpected doctor output type'); + const text = allText(result); - expect(text).toContain('Output Mode: ⚠️ Non-redacted (opt-in)'); + expect(text).toContain('Output Mode: Non-redacted (opt-in)'); expect(text).toContain('testuser'); expect(text).toContain('MySecretProject'); }); @@ -368,13 +344,10 @@ describe('doctor tool', () => { const result = await runDoctor({}, deps); - expect(result.content).toEqual([ - { - type: 'text', - text: result.content[0].text, - }, - ]); - expect(typeof result.content[0].text).toBe('string'); + expect(result.content.length).toBeGreaterThan(0); + const text = allText(result); + expect(text).toContain('Available: No'); + expect(text).toContain('UI Automation Supported: No'); }); }); }); diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 77968d1f..5b3ad050 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -9,15 +9,16 @@ import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { version } from '../../../utils/version/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; import { getConfig } from '../../../utils/config-store.ts'; import { detectXcodeRuntime } from '../../../utils/xcode-process.ts'; import { type DoctorDependencies, createDoctorDependencies } from './lib/doctor.deps.ts'; import { peekXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; import { getMcpBridgeAvailability } from '../../../integrations/xcode-tools-bridge/core.ts'; +import { header, statusLine, section, detailTree } from '../../../utils/tool-event-builders.ts'; +import { renderEvents } from '../../../rendering/render.ts'; -// Constants const LOG_PREFIX = '[Doctor]'; const USER_HOME_PATH_PATTERN = /\/Users\/[^/\s]+/g; const SENSITIVE_KEY_PATTERN = @@ -25,7 +26,6 @@ const SENSITIVE_KEY_PATTERN = const SECRET_VALUE_PATTERN = /((token|secret|password|passphrase|api[_-]?key|auth|cookie|session|private[_-]?key)\s*[=:]\s*)([^\s,;]+)/gi; -// Define schema as ZodObject const doctorSchema = z.object({ nonRedacted: z .boolean() @@ -33,7 +33,6 @@ const doctorSchema = z.object({ .describe('Opt-in: when true, disable redaction and include full raw doctor output.'), }); -// Use z.infer for type safety type DoctorParams = z.infer; function escapeRegExp(value: string): string { @@ -162,16 +161,13 @@ async function getXcodeToolsBridgeDoctorInfo( } /** - * Run the doctor tool and return the results + * Run the doctor tool and return the results. */ -export async function runDoctor( - params: DoctorParams, - deps: DoctorDependencies, - showAsciiLogo = false, -): Promise { +export async function runDoctor(params: DoctorParams, deps: DoctorDependencies) { const prevSilence = process.env.XCODEBUILDMCP_SILENCE_LOGS; process.env.XCODEBUILDMCP_SILENCE_LOGS = 'true'; log('info', `${LOG_PREFIX}: Running doctor tool`); + try { const xcodemakeEnabled = deps.features.isXcodemakeEnabled(); const requiredBinaries = ['axe', 'mise', ...(xcodemakeEnabled ? ['xcodemake'] : [])]; @@ -263,231 +259,266 @@ export async function runDoctor( ? doctorInfoRaw : (sanitizeValue(doctorInfoRaw, '', projectNames, piiTerms) as typeof doctorInfoRaw); - // Custom ASCII banner (multiline) - const asciiLogo = ` -██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ ██╗ ██╗██╗██╗ ██████╗ ███╗ ███╗ ██████╗██████╗ -╚██╗██╔╝██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗██║ ██║██║██║ ██╔══██╗████╗ ████║██╔════╝██╔══██╗ - ╚███╔╝ ██║ ██║ ██║██║ ██║█████╗ ██████╔╝██║ ██║██║██║ ██║ ██║██╔████╔██║██║ ██████╔╝ - ██╔██╗ ██║ ██║ ██║██║ ██║██╔══╝ ██╔══██╗██║ ██║██║██║ ██║ ██║██║╚██╔╝██║██║ ██╔═══╝ -██╔╝ ██╗╚██████╗╚██████╔╝██████╔╝███████╗██████╔╝╚██████╔╝██║███████╗██████╔╝██║ ╚═╝ ██║╚██████╗██║ -╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ - -██████╗ ██████╗ ██████╗████████╗ ██████╗ ██████╗ -██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗ -██║ ██║██║ ██║██║ ██║ ██║ ██║██████╔╝ -██║ ██║██║ ██║██║ ██║ ██║ ██║██╔══██╗ -██████╔╝╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║ ██║ -╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ -`; - - const RESET = '\x1b[0m'; - // 256-color: orangey-pink foreground and lighter shade for outlines - const FOREGROUND = '\x1b[38;5;209m'; - const SHADOW = '\x1b[38;5;217m'; - - function colorizeAsciiArt(ascii: string): string { - const lines = ascii.split('\n'); - const coloredLines: string[] = []; - const shadowChars = new Set([ - '╔', - '╗', - '╝', - '╚', - '═', - '║', - '╦', - '╩', - '╠', - '╣', - '╬', - '┌', - '┐', - '└', - '┘', - '│', - '─', - ]); - for (const line of lines) { - let colored = ''; - for (const ch of line) { - if (ch === '█') { - colored += `${FOREGROUND}${ch}${RESET}`; - } else if (shadowChars.has(ch)) { - colored += `${SHADOW}${ch}${RESET}`; - } else { - colored += ch; - } - } - coloredLines.push(colored + RESET); + const events: PipelineEvent[] = [ + header('XcodeBuildMCP Doctor', [ + { label: 'Generated', value: doctorInfo.timestamp }, + { label: 'Server Version', value: doctorInfo.serverVersion }, + { + label: 'Output Mode', + value: params.nonRedacted ? 'Non-redacted (opt-in)' : 'Redacted (default)', + }, + ]), + ]; + + // System Information + events.push( + detailTree( + Object.entries(doctorInfo.system).map(([key, value]) => ({ + label: key, + value: String(value), + })), + ), + ); + + // Node.js Information + events.push( + section( + 'Node.js Information', + Object.entries(doctorInfo.node).map(([key, value]) => `${key}: ${value}`), + ), + ); + + // Process Tree + const processTreeLines: string[] = [ + `Running under Xcode: ${doctorInfo.runningUnderXcode ? 'Yes' : 'No'}`, + ]; + if (doctorInfo.processTree.length > 0) { + for (const entry of doctorInfo.processTree) { + processTreeLines.push( + `${entry.pid} (ppid ${entry.ppid}): ${entry.name}${entry.command ? ` -- ${entry.command}` : ''}`, + ); } - return coloredLines.join('\n'); + } else { + processTreeLines.push('(unavailable)'); + } + if (doctorInfo.processTreeError) { + processTreeLines.push(`Error: ${doctorInfo.processTreeError}`); + } + events.push(section('Process Tree', processTreeLines)); + + // Xcode Information + if ('error' in doctorInfo.xcode) { + events.push( + section('Xcode Information', [`Error: ${doctorInfo.xcode.error}`], { icon: 'cross' }), + ); + } else { + events.push( + section( + 'Xcode Information', + Object.entries(doctorInfo.xcode).map(([key, value]) => `${key}: ${value}`), + ), + ); } - const outputLines = []; + // Dependencies + events.push( + section( + 'Dependencies', + Object.entries(doctorInfo.dependencies).map( + ([binary, status]) => + `${binary}: ${status.available ? (status.version ?? 'Available') : 'Not found'}`, + ), + ), + ); - // Only show ASCII logo when explicitly requested (CLI usage) - if (showAsciiLogo) { - outputLines.push(colorizeAsciiArt(asciiLogo)); + // Environment Variables + const envLines = Object.entries(doctorInfo.environmentVariables) + .filter(([key]) => key !== 'PATH' && key !== 'PYTHONPATH') + .map(([key, value]) => `${key}: ${value ?? '(not set)'}`); + events.push(section('Environment Variables', envLines)); + + // PATH + const pathValue = doctorInfo.environmentVariables.PATH ?? '(not set)'; + events.push(section('PATH', pathValue.split(':'))); + + // UI Automation (axe) + const axeLines: string[] = [ + `Available: ${doctorInfo.features.axe.available ? 'Yes' : 'No'}`, + `UI Automation Supported: ${doctorInfo.features.axe.uiAutomationSupported ? 'Yes' : 'No'}`, + `Simulator Video Capture Supported (AXe >= 1.1.0): ${doctorInfo.features.axe.videoCaptureSupported ? 'Yes' : 'No'}`, + `UI-Debugger Guard Mode: ${uiDebuggerGuardMode}`, + ]; + events.push(section('UI Automation (axe)', axeLines)); + + // Incremental Builds + let makefileStatus: string; + if (doctorInfo.features.xcodemake.makefileExists === null) { + makefileStatus = '(not checked: incremental builds disabled)'; + } else { + makefileStatus = doctorInfo.features.xcodemake.makefileExists ? 'Yes' : 'No'; } + events.push( + section('Incremental Builds', [ + `Enabled: ${doctorInfo.features.xcodemake.enabled ? 'Yes' : 'No'}`, + `xcodemake Binary Available: ${doctorInfo.features.xcodemake.binaryAvailable ? 'Yes' : 'No'}`, + `Makefile exists (cwd): ${makefileStatus}`, + ]), + ); - outputLines.push( - 'XcodeBuildMCP Doctor', - `\nGenerated: ${doctorInfo.timestamp}`, - `Server Version: ${doctorInfo.serverVersion}`, - `Output Mode: ${params.nonRedacted ? '⚠️ Non-redacted (opt-in)' : 'Redacted (default)'}`, + // Mise Integration + events.push( + section('Mise Integration', [ + `Running under mise: ${doctorInfo.features.mise.running_under_mise ? 'Yes' : 'No'}`, + `Mise available: ${doctorInfo.features.mise.available ? 'Yes' : 'No'}`, + ]), ); - const formattedOutput = [ - ...outputLines, - - `\n## System Information`, - ...Object.entries(doctorInfo.system).map(([key, value]) => `- ${key}: ${value}`), - - `\n## Node.js Information`, - ...Object.entries(doctorInfo.node).map(([key, value]) => `- ${key}: ${value}`), - - `\n## Process Tree`, - `- Running under Xcode: ${doctorInfo.runningUnderXcode ? '✅ Yes' : '❌ No'}`, - ...(doctorInfo.processTree.length > 0 - ? doctorInfo.processTree.map( - (entry) => - `- ${entry.pid} (ppid ${entry.ppid}): ${entry.name}${ - entry.command ? ` — ${entry.command}` : '' - }`, - ) - : ['- (unavailable)']), - ...(doctorInfo.processTreeError ? [`- Error: ${doctorInfo.processTreeError}`] : []), - - `\n## Xcode Information`, - ...('error' in doctorInfo.xcode - ? [`- Error: ${doctorInfo.xcode.error}`] - : Object.entries(doctorInfo.xcode).map(([key, value]) => `- ${key}: ${value}`)), - - `\n## Dependencies`, - ...Object.entries(doctorInfo.dependencies).map( - ([binary, status]) => - `- ${binary}: ${status.available ? `✅ ${status.version ?? 'Available'}` : '❌ Not found'}`, - ), + // Debugger Backend (DAP) + const debuggerLines: string[] = [ + `lldb-dap available: ${doctorInfo.features.debugger.dap.available ? 'Yes' : 'No'}`, + `Selected backend: ${doctorInfo.features.debugger.dap.selected}`, + ]; + if (dapSelected && !lldbDapAvailable) { + debuggerLines.push( + 'Warning: DAP backend selected but lldb-dap not available. Set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli to use the CLI backend.', + ); + } + events.push(section('Debugger Backend (DAP)', debuggerLines)); + + // Manifest Tool Inventory + if ('error' in doctorInfo.manifestTools) { + events.push( + section('Manifest Tool Inventory', [`Error: ${doctorInfo.manifestTools.error}`], { + icon: 'cross', + }), + ); + } else { + events.push( + section('Manifest Tool Inventory', [ + `Total Unique Tools: ${doctorInfo.manifestTools.totalTools}`, + `Workflow Count: ${doctorInfo.manifestTools.workflowCount}`, + ...Object.entries(doctorInfo.manifestTools.toolsByWorkflow).map( + ([workflow, count]) => `${workflow}: ${count} tools`, + ), + ]), + ); + } - `\n## Environment Variables`, - ...Object.entries(doctorInfo.environmentVariables) - .filter(([key]) => key !== 'PATH' && key !== 'PYTHONPATH') // These are too long, handle separately - .map(([key, value]) => `- ${key}: ${value ?? '(not set)'}`), - - `\n### PATH`, - `\`\`\``, - `${doctorInfo.environmentVariables.PATH ?? '(not set)'}`.split(':').join('\n'), - `\`\`\``, - - `\n## Feature Status`, - `\n### UI Automation (axe)`, - `- Available: ${doctorInfo.features.axe.available ? '✅ Yes' : '❌ No'}`, - `- UI Automation Supported: ${doctorInfo.features.axe.uiAutomationSupported ? '✅ Yes' : '❌ No'}`, - `- Simulator Video Capture Supported (AXe >= 1.1.0): ${doctorInfo.features.axe.videoCaptureSupported ? '✅ Yes' : '❌ No'}`, - `- UI-Debugger Guard Mode: ${uiDebuggerGuardMode}`, - - `\n### Incremental Builds`, - `- Enabled: ${doctorInfo.features.xcodemake.enabled ? '✅ Yes' : '❌ No'}`, - `- xcodemake Binary Available: ${doctorInfo.features.xcodemake.binaryAvailable ? '✅ Yes' : '❌ No'}`, - `- Makefile exists (cwd): ${doctorInfo.features.xcodemake.makefileExists === null ? '(not checked: incremental builds disabled)' : doctorInfo.features.xcodemake.makefileExists ? '✅ Yes' : '❌ No'}`, - - `\n### Mise Integration`, - `- Running under mise: ${doctorInfo.features.mise.running_under_mise ? '✅ Yes' : '❌ No'}`, - `- Mise available: ${doctorInfo.features.mise.available ? '✅ Yes' : '❌ No'}`, - - `\n### Debugger Backend (DAP)`, - `- lldb-dap available: ${doctorInfo.features.debugger.dap.available ? '✅ Yes' : '❌ No'}`, - `- Selected backend: ${doctorInfo.features.debugger.dap.selected}`, - ...(dapSelected && !lldbDapAvailable - ? [ - `- Warning: DAP backend selected but lldb-dap not available. Set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli to use the CLI backend.`, - ] - : []), - - `\n### Manifest Tool Inventory`, - ...('error' in doctorInfo.manifestTools - ? [`- Error: ${doctorInfo.manifestTools.error}`] - : [ - `- Total Unique Tools: ${doctorInfo.manifestTools.totalTools}`, - `- Workflow Count: ${doctorInfo.manifestTools.workflowCount}`, - ...Object.entries(doctorInfo.manifestTools.toolsByWorkflow).map( - ([workflow, count]) => `- ${workflow}: ${count} tools`, - ), - ]), - - `\n### Runtime Tool Registration`, - `- Enabled Workflows: ${runtimeRegistration.enabledWorkflows.length}`, - `- Registered Tools: ${runtimeRegistration.registeredToolCount}`, - ...(runtimeNote ? [`- Note: ${runtimeNote}`] : []), - ...(runtimeRegistration.enabledWorkflows.length > 0 - ? [`- Workflows: ${runtimeRegistration.enabledWorkflows.join(', ')}`] - : []), - - `\n### Xcode IDE Bridge (mcpbridge)`, - ...(doctorInfo.xcodeToolsBridge.available - ? [ - `- Workflow enabled: ${doctorInfo.xcodeToolsBridge.workflowEnabled ? '✅ Yes' : '❌ No'}`, - `- mcpbridge path: ${doctorInfo.xcodeToolsBridge.bridgePath ?? '(not found)'}`, - `- Xcode running: ${doctorInfo.xcodeToolsBridge.xcodeRunning ?? '(unknown)'}`, - `- Connected: ${doctorInfo.xcodeToolsBridge.connected ? '✅ Yes' : '❌ No'}`, - `- Bridge PID: ${doctorInfo.xcodeToolsBridge.bridgePid ?? '(none)'}`, - `- Proxied tools: ${doctorInfo.xcodeToolsBridge.proxiedToolCount}`, - `- Last error: ${doctorInfo.xcodeToolsBridge.lastError ?? '(none)'}`, - `- Note: Bridge debug tools (status/sync/disconnect) are only registered when debug: true`, - ] - : [`- Unavailable: ${doctorInfo.xcodeToolsBridge.reason}`]), - - `\n## Tool Availability Summary`, - `- Build Tools: ${!('error' in doctorInfo.xcode) ? '\u2705 Available' : '\u274c Not available'}`, - `- UI Automation Tools: ${doctorInfo.features.axe.uiAutomationSupported ? '\u2705 Available' : '\u274c Not available'}`, - `- Incremental Build Support: ${doctorInfo.features.xcodemake.binaryAvailable && doctorInfo.features.xcodemake.enabled ? '\u2705 Available & Enabled' : doctorInfo.features.xcodemake.binaryAvailable ? '\u2705 Available but Disabled' : '\u274c Not available'}`, - - `\n## Sentry`, - `- Sentry enabled: ${doctorInfo.environmentVariables.SENTRY_DISABLED !== 'true' ? '✅ Yes' : '❌ No'}`, - - `\n## Troubleshooting Tips`, - `- If UI automation tools are not available, install axe: \`brew tap cameroncooke/axe && brew install axe\``, - `- If incremental build support is not available, install xcodemake (https://github.com/cameroncooke/xcodemake) and ensure it is executable and available in your PATH`, - `- To enable xcodemake, set environment variable: \`export INCREMENTAL_BUILDS_ENABLED=1\``, - `- For mise integration, follow instructions in the README.md file`, - ].join('\n'); - - const result: ToolResponse = { - content: [ - { - type: 'text', - text: formattedOutput, - }, - ], - }; - // Restore previous silence flag - if (prevSilence === undefined) { - delete process.env.XCODEBUILDMCP_SILENCE_LOGS; + // Runtime Tool Registration + const runtimeLines: string[] = [ + `Enabled Workflows: ${runtimeRegistration.enabledWorkflows.length}`, + `Registered Tools: ${runtimeRegistration.registeredToolCount}`, + ]; + if (runtimeNote) { + runtimeLines.push(`Note: ${runtimeNote}`); + } + if (runtimeRegistration.enabledWorkflows.length > 0) { + runtimeLines.push(`Workflows: ${runtimeRegistration.enabledWorkflows.join(', ')}`); + } + events.push(section('Runtime Tool Registration', runtimeLines)); + + // Xcode IDE Bridge + if (doctorInfo.xcodeToolsBridge.available) { + events.push( + section('Xcode IDE Bridge (mcpbridge)', [ + `Workflow enabled: ${doctorInfo.xcodeToolsBridge.workflowEnabled ? 'Yes' : 'No'}`, + `mcpbridge path: ${doctorInfo.xcodeToolsBridge.bridgePath ?? '(not found)'}`, + `Xcode running: ${doctorInfo.xcodeToolsBridge.xcodeRunning ?? '(unknown)'}`, + `Connected: ${doctorInfo.xcodeToolsBridge.connected ? 'Yes' : 'No'}`, + `Bridge PID: ${doctorInfo.xcodeToolsBridge.bridgePid ?? '(none)'}`, + `Proxied tools: ${doctorInfo.xcodeToolsBridge.proxiedToolCount}`, + `Last error: ${doctorInfo.xcodeToolsBridge.lastError ?? '(none)'}`, + 'Note: Bridge debug tools (status/sync/disconnect) are only registered when debug: true', + ]), + ); } else { - process.env.XCODEBUILDMCP_SILENCE_LOGS = prevSilence; + events.push( + section('Xcode IDE Bridge (mcpbridge)', [ + `Unavailable: ${doctorInfo.xcodeToolsBridge.reason}`, + ]), + ); + } + + // Tool Availability Summary + const buildToolsAvailable = !('error' in doctorInfo.xcode); + let incrementalStatus: string; + if (doctorInfo.features.xcodemake.binaryAvailable && doctorInfo.features.xcodemake.enabled) { + incrementalStatus = 'Available & Enabled'; + } else if (doctorInfo.features.xcodemake.binaryAvailable) { + incrementalStatus = 'Available but Disabled'; + } else { + incrementalStatus = 'Not available'; + } + events.push( + section('Tool Availability Summary', [ + `Build Tools: ${buildToolsAvailable ? 'Available' : 'Not available'}`, + `UI Automation Tools: ${doctorInfo.features.axe.uiAutomationSupported ? 'Available' : 'Not available'}`, + `Incremental Build Support: ${incrementalStatus}`, + ]), + ); + + // Sentry + events.push( + section('Sentry', [ + `Sentry enabled: ${doctorInfo.environmentVariables.SENTRY_DISABLED !== 'true' ? 'Yes' : 'No'}`, + ]), + ); + + // Troubleshooting Tips + events.push( + section('Troubleshooting Tips', [ + 'If UI automation tools are not available, install axe: brew tap cameroncooke/axe && brew install axe', + 'If incremental build support is not available, install xcodemake (https://github.com/cameroncooke/xcodemake) and ensure it is executable and available in your PATH', + 'To enable xcodemake, set environment variable: export INCREMENTAL_BUILDS_ENABLED=1', + 'For mise integration, follow instructions in the README.md file', + ]), + ); + + events.push(statusLine('success', 'Doctor diagnostics complete')); + + const rendered = renderEvents(events, 'text'); + const hasError = events.some( + (e) => + (e.type === 'status-line' && e.level === 'error') || + (e.type === 'summary' && e.status === 'FAILED'), + ); + return { + content: [{ type: 'text' as const, text: rendered }], + isError: hasError || undefined, + _meta: { events: [...events] }, + }; + + } finally { + if (prevSilence === undefined) { + delete process.env.XCODEBUILDMCP_SILENCE_LOGS; + } else { + process.env.XCODEBUILDMCP_SILENCE_LOGS = prevSilence; + } } - return result; } -export async function doctorLogic( - params: DoctorParams, - executor: CommandExecutor, - showAsciiLogo = false, -): Promise { +export async function doctorLogic(params: DoctorParams, executor: CommandExecutor) { const deps = createDoctorDependencies(executor); - return runDoctor(params, deps, showAsciiLogo); + return runDoctor(params, deps); } -// MCP wrapper that ensures ASCII logo is never shown for MCP server calls -async function doctorMcpHandler( +export async function doctorToolLogic( params: DoctorParams, executor: CommandExecutor, -): Promise { - return doctorLogic(params, executor, false); // Always false for MCP +): Promise { + const ctx = getHandlerContext(); + const response = await doctorLogic(params, executor); + + const events = response._meta?.events; + if (Array.isArray(events)) { + for (const event of events as PipelineEvent[]) { + ctx.emit(event); + } + } } -export const schema = doctorSchema.shape; // MCP SDK compatibility +export const schema = doctorSchema.shape; -export const handler = createTypedTool(doctorSchema, doctorMcpHandler, getDefaultCommandExecutor); +export const handler = createTypedTool(doctorSchema, doctorToolLogic, getDefaultCommandExecutor); export type { DoctorDependencies } from './lib/doctor.deps.ts'; diff --git a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts index 1a5180ed..f70a3d90 100644 --- a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts @@ -1,26 +1,11 @@ -/** - * Pure dependency injection test for discover_projs plugin - * - * Tests the plugin structure and project discovery functionality - * including parameter validation, file system operations, and response formatting. - * - * Uses createMockFileSystemExecutor for file system operations. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, handler, discover_projsLogic, discoverProjects } from '../discover_projs.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; -describe('discover_projs plugin', () => { - let mockFileSystemExecutor: any; - - // Create mock file system executor - mockFileSystemExecutor = createMockFileSystemExecutor({ - stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), - readdir: async () => [], - }); +describe('discover_projs plugin', () => { describe('Export Field Validation (Literal)', () => { it('should have handler function', () => { expect(typeof handler).toBe('function'); @@ -57,13 +42,15 @@ describe('discover_projs plugin', () => { }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { + describe('Discovery behavior', () => { it('returns structured discovery results for setup flows', async () => { - mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); - mockFileSystemExecutor.readdir = async () => [ - { name: 'App.xcodeproj', isDirectory: () => true, isSymbolicLink: () => false }, - { name: 'App.xcworkspace', isDirectory: () => true, isSymbolicLink: () => false }, - ]; + const mockFileSystemExecutor = createMockFileSystemExecutor({ + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async () => [ + { name: 'App.xcodeproj', isDirectory: () => true, isSymbolicLink: () => false }, + { name: 'App.xcworkspace', isDirectory: () => true, isSymbolicLink: () => false }, + ], + }); const result = await discoverProjects( { workspaceRoot: '/workspace' }, @@ -73,305 +60,108 @@ describe('discover_projs plugin', () => { expect(result.workspaces).toEqual(['/workspace/App.xcworkspace']); }); - it('should handle workspaceRoot parameter correctly when provided', async () => { - mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); - mockFileSystemExecutor.readdir = async () => []; - - const result = await discover_projsLogic( - { workspaceRoot: '/workspace' }, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); - }); - - it('should return error when scan path does not exist', async () => { - mockFileSystemExecutor.stat = async () => { - throw new Error('ENOENT: no such file or directory'); - }; - - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', - scanPath: '.', - maxDepth: 5, + it('tolerates recursive directory read errors and returns empty results', async () => { + const mockFileSystemExecutor = createMockFileSystemExecutor({ + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async () => { + const readError = new Error('Permission denied'); + (readError as Error & { code?: string }).code = 'EACCES'; + throw readError; }, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to access scan path: /workspace. Error: ENOENT: no such file or directory', - }, - ], - isError: true, }); - }); - - it('should return error when scan path is not a directory', async () => { - mockFileSystemExecutor.stat = async () => ({ isDirectory: () => false, mtimeMs: 0 }); - - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', - scanPath: '.', - maxDepth: 5, - }, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [{ type: 'text', text: 'Scan path is not a directory: /workspace' }], - isError: true, - }); - }); - - it('should return success with no projects found', async () => { - mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); - mockFileSystemExecutor.readdir = async () => []; - - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', - scanPath: '.', - maxDepth: 5, - }, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); - }); - - it('should return success with projects found', async () => { - mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); - mockFileSystemExecutor.readdir = async () => [ - { name: 'MyApp.xcodeproj', isDirectory: () => true, isSymbolicLink: () => false }, - { name: 'MyWorkspace.xcworkspace', isDirectory: () => true, isSymbolicLink: () => false }, - ]; - - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', - scanPath: '.', - maxDepth: 5, - }, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: 'Discovery finished. Found 1 projects and 1 workspaces.' }, - { type: 'text', text: 'Projects found:\n - /workspace/MyApp.xcodeproj' }, - { type: 'text', text: 'Workspaces found:\n - /workspace/MyWorkspace.xcworkspace' }, - { - type: 'text', - text: "Hint: Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }.", - }, - ], - isError: false, - }); - }); - - it('should handle fs error with code', async () => { - const error = new Error('Permission denied'); - (error as any).code = 'EACCES'; - mockFileSystemExecutor.stat = async () => { - throw error; - }; - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', - scanPath: '.', - maxDepth: 5, - }, + const result = await discoverProjects( + { workspaceRoot: '/workspace' }, mockFileSystemExecutor, ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to access scan path: /workspace. Error: Permission denied', - }, + expect(result.projects).toEqual([]); + expect(result.workspaces).toEqual([]); + }); + + it('skips ignored directory types during scan', async () => { + const mockFileSystemExecutor = createMockFileSystemExecutor({ + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async () => [ + { name: 'build', isDirectory: () => true, isSymbolicLink: () => false }, + { name: 'DerivedData', isDirectory: () => true, isSymbolicLink: () => false }, + { name: 'symlink', isDirectory: () => true, isSymbolicLink: () => true }, + { name: 'regular.txt', isDirectory: () => false, isSymbolicLink: () => false }, ], - isError: true, }); - }); - - it('should handle string error', async () => { - mockFileSystemExecutor.stat = async () => { - throw 'String error'; - }; - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', - scanPath: '.', - maxDepth: 5, - }, + const result = await discoverProjects( + { workspaceRoot: '/workspace' }, mockFileSystemExecutor, ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: 'Failed to access scan path: /workspace. Error: String error' }, - ], - isError: true, - }); + expect(result.projects).toEqual([]); + expect(result.workspaces).toEqual([]); }); - it('should handle workspaceRoot parameter correctly', async () => { - mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); - mockFileSystemExecutor.readdir = async () => []; - - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', + it('stops recursion at max depth', async () => { + let readdirCallCount = 0; + const mockFileSystemExecutor = createMockFileSystemExecutor({ + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async () => { + readdirCallCount += 1; + if (readdirCallCount <= 3) { + return [ + { + name: `subdir${readdirCallCount}`, + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; }, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, }); - }); - it('should handle scan path outside workspace root', async () => { - // Mock path normalization to simulate path outside workspace root - mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); - mockFileSystemExecutor.readdir = async () => []; - - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', - scanPath: '../outside', - maxDepth: 5, - }, + const result = await discoverProjects( + { workspaceRoot: '/workspace', scanPath: '.', maxDepth: 3 }, mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); + expect(result.projects).toEqual([]); + expect(result.workspaces).toEqual([]); + expect(readdirCallCount).toBe(3); }); + }); - it('should handle error with object containing message and code properties', async () => { - const errorObject = { - message: 'Access denied', - code: 'EACCES', - }; - mockFileSystemExecutor.stat = async () => { - throw errorObject; - }; - - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', - scanPath: '.', - maxDepth: 5, + describe('Logic error handling', () => { + it('returns error when scan path does not exist', async () => { + const mockFileSystemExecutor = createMockFileSystemExecutor({ + stat: async () => { + throw new Error('ENOENT: no such file or directory'); }, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: 'Failed to access scan path: /workspace. Error: Access denied' }, - ], - isError: true, + readdir: async () => [], }); - }); - it('should handle max depth reached during recursive scan', async () => { - let readdirCallCount = 0; - - mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); - mockFileSystemExecutor.readdir = async () => { - readdirCallCount++; - if (readdirCallCount <= 3) { - return [ - { - name: `subdir${readdirCallCount}`, - isDirectory: () => true, - isSymbolicLink: () => false, - }, - ]; - } - return []; - }; - - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', - scanPath: '.', - maxDepth: 3, - }, - mockFileSystemExecutor, + const result = await runLogic(() => + discover_projsLogic( + { workspaceRoot: '/workspace', scanPath: '.', maxDepth: 5 }, + mockFileSystemExecutor, + ), ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); - it('should handle skipped directory types during scan', async () => { - mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); - mockFileSystemExecutor.readdir = async () => [ - { name: 'build', isDirectory: () => true, isSymbolicLink: () => false }, - { name: 'DerivedData', isDirectory: () => true, isSymbolicLink: () => false }, - { name: 'symlink', isDirectory: () => true, isSymbolicLink: () => true }, - { name: 'regular.txt', isDirectory: () => false, isSymbolicLink: () => false }, - ]; - - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', - scanPath: '.', - maxDepth: 5, - }, - mockFileSystemExecutor, - ); - - // Test that skipped directories and files are correctly filtered out - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, + it('returns error when scan path is not a directory', async () => { + const mockFileSystemExecutor = createMockFileSystemExecutor({ + stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), + readdir: async () => [], }); - }); - - it('should handle error during recursive directory reading', async () => { - mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); - mockFileSystemExecutor.readdir = async () => { - const readError = new Error('Permission denied'); - (readError as any).code = 'EACCES'; - throw readError; - }; - const result = await discover_projsLogic( - { - workspaceRoot: '/workspace', - scanPath: '.', - maxDepth: 5, - }, - mockFileSystemExecutor, + const result = await runLogic(() => + discover_projsLogic( + { workspaceRoot: '/workspace', scanPath: '.', maxDepth: 5 }, + mockFileSystemExecutor, + ), ); - // The function should handle the error gracefully and continue - expect(result).toEqual({ - content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], - isError: false, - }); + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts index f808f0dc..53acb733 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts @@ -1,23 +1,15 @@ -/** - * Test for get_app_bundle_id plugin - Dependency Injection Architecture - * - * Tests the plugin structure and exported components for get_app_bundle_id tool. - * Uses pure dependency injection with createMockFileSystemExecutor. - * NO VITEST MOCKING ALLOWED - Only createMockFileSystemExecutor - * - * Plugin location: plugins/project-discovery/get_app_bundle_id.ts - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, handler, get_app_bundle_idLogic } from '../get_app_bundle_id.ts'; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + + import { createMockFileSystemExecutor, createCommandMatchingMockExecutor, } from '../../../../test-utils/mock-executors.ts'; describe('get_app_bundle_id plugin', () => { - // Helper function to create mock executor for command matching const createMockExecutorForCommands = (results: Record) => { return createCommandMatchingMockExecutor( Object.fromEntries( @@ -51,20 +43,13 @@ describe('get_app_bundle_id plugin', () => { }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { + describe('Handler behavior', () => { it('should return error when appPath validation fails', async () => { - // Test validation through the handler which uses Zod validation const result = await handler({}); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received undefined', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('appPath'); }); it('should return error when file exists validation fails', async () => { @@ -73,21 +58,16 @@ describe('get_app_bundle_id plugin', () => { existsSync: () => false, }); - const result = await get_app_bundle_idLogic( - { appPath: '/path/to/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, + const result = await runLogic(() => + get_app_bundle_idLogic( + { appPath: '/path/to/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "File not found: '/path/to/MyApp.app'. Please check the path and try again.", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); it('should return success with bundle ID using defaults read', async () => { @@ -98,26 +78,20 @@ describe('get_app_bundle_id plugin', () => { existsSync: () => true, }); - const result = await get_app_bundle_idLogic( - { appPath: '/path/to/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, + const result = await runLogic(() => + get_app_bundle_idLogic( + { appPath: '/path/to/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ Bundle ID: io.sentry.MyApp', - }, - ], - nextStepParams: { - install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, - launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, - install_app_device: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, - launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, - }, - isError: false, + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, }); }); @@ -133,26 +107,20 @@ describe('get_app_bundle_id plugin', () => { existsSync: () => true, }); - const result = await get_app_bundle_idLogic( - { appPath: '/path/to/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, + const result = await runLogic(() => + get_app_bundle_idLogic( + { appPath: '/path/to/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ Bundle ID: io.sentry.MyApp', - }, - ], - nextStepParams: { - install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, - launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, - install_app_device: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, - launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, - }, - isError: false, + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, }); }); @@ -168,151 +136,24 @@ describe('get_app_bundle_id plugin', () => { existsSync: () => true, }); - const result = await get_app_bundle_idLogic( - { appPath: '/path/to/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: Command failed', - }, - { - type: 'text', - text: 'Make sure the path points to a valid app bundle (.app directory).', - }, - ], - isError: true, - }); - }); - - it('should handle Error objects in catch blocks', async () => { - const mockExecutor = createMockExecutorForCommands({ - 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error( - 'defaults read failed', - ), - '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"': - new Error('Custom error message'), - }); - const mockFileSystemExecutor = createMockFileSystemExecutor({ - existsSync: () => true, - }); - - const result = await get_app_bundle_idLogic( - { appPath: '/path/to/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: Custom error message', - }, - { - type: 'text', - text: 'Make sure the path points to a valid app bundle (.app directory).', - }, - ], - isError: true, - }); - }); - - it('should handle string errors in catch blocks', async () => { - const mockExecutor = createMockExecutorForCommands({ - 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error( - 'defaults read failed', + const result = await runLogic(() => + get_app_bundle_idLogic( + { appPath: '/path/to/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, ), - '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"': - new Error('String error'), - }); - const mockFileSystemExecutor = createMockFileSystemExecutor({ - existsSync: () => true, - }); - - const result = await get_app_bundle_idLogic( - { appPath: '/path/to/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: String error', - }, - { - type: 'text', - text: 'Make sure the path points to a valid app bundle (.app directory).', - }, - ], - isError: true, - }); - }); - - it('should handle schema validation error when appPath is null', async () => { - // Test validation through the handler which uses Zod validation - const result = await handler({ appPath: null }); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received null', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); - it('should handle schema validation with missing appPath', async () => { - // Test validation through the handler which uses Zod validation - const result = await handler({}); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received undefined', - }, - ], - isError: true, - }); - }); - - it('should handle schema validation with undefined appPath', async () => { - // Test validation through the handler which uses Zod validation - const result = await handler({ appPath: undefined }); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received undefined', - }, - ], - isError: true, - }); - }); - - it('should handle schema validation with number type appPath', async () => { - // Test validation through the handler which uses Zod validation + it('should reject non-string appPath values through the handler', async () => { const result = await handler({ appPath: 123 }); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Invalid input: expected string, received number', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('appPath'); }); }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts index 367dfd05..76561c2d 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts @@ -1,13 +1,14 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import * as z from 'zod'; +import { describe, it, expect } from 'vitest'; import { schema, handler, get_mac_bundle_idLogic } from '../get_mac_bundle_id.ts'; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + + import { createMockFileSystemExecutor, createCommandMatchingMockExecutor, } from '../../../../test-utils/mock-executors.ts'; describe('get_mac_bundle_id plugin', () => { - // Helper function to create mock executor for command matching const createMockExecutorForCommands = (results: Record) => { return createCommandMatchingMockExecutor( Object.fromEntries( @@ -21,51 +22,30 @@ describe('get_mac_bundle_id plugin', () => { ); }; - describe('Export Field Validation (Literal)', () => { - it('should have handler function', () => { + describe('Plugin Structure', () => { + it('should expose schema and handler', () => { + expect(schema).toBeDefined(); expect(typeof handler).toBe('function'); }); - - it('should validate schema with valid inputs', () => { - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({ appPath: '/Applications/TextEdit.app' }).success).toBe(true); - expect(schemaObj.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true); - }); - - it('should validate schema with invalid inputs', () => { - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({}).success).toBe(false); - expect(schemaObj.safeParse({ appPath: 123 }).success).toBe(false); - expect(schemaObj.safeParse({ appPath: null }).success).toBe(false); - expect(schemaObj.safeParse({ appPath: undefined }).success).toBe(false); - }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { - // Note: appPath validation is now handled by Zod schema validation in createTypedTool - // This test would not reach the logic function as Zod validation occurs before it - + describe('Handler behavior', () => { it('should return error when file exists validation fails', async () => { const mockExecutor = createMockExecutorForCommands({}); const mockFileSystemExecutor = createMockFileSystemExecutor({ existsSync: () => false, }); - const result = await get_mac_bundle_idLogic( - { appPath: '/Applications/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, + const result = await runLogic(() => + get_mac_bundle_idLogic( + { appPath: '/Applications/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "File not found: '/Applications/MyApp.app'. Please check the path and try again.", - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); it('should return success with bundle ID using defaults read', async () => { @@ -77,24 +57,18 @@ describe('get_mac_bundle_id plugin', () => { existsSync: () => true, }); - const result = await get_mac_bundle_idLogic( - { appPath: '/Applications/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, + const result = await runLogic(() => + get_mac_bundle_idLogic( + { appPath: '/Applications/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ Bundle ID: io.sentry.MyMacApp', - }, - ], - nextStepParams: { - launch_mac_app: { appPath: '/Applications/MyApp.app' }, - build_macos: { scheme: 'SCHEME_NAME' }, - }, - isError: false, + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + launch_mac_app: { appPath: '/Applications/MyApp.app' }, + build_macos: { scheme: 'SCHEME_NAME' }, }); }); @@ -110,24 +84,18 @@ describe('get_mac_bundle_id plugin', () => { existsSync: () => true, }); - const result = await get_mac_bundle_idLogic( - { appPath: '/Applications/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, + const result = await runLogic(() => + get_mac_bundle_idLogic( + { appPath: '/Applications/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ Bundle ID: io.sentry.MyMacApp', - }, - ], - nextStepParams: { - launch_mac_app: { appPath: '/Applications/MyApp.app' }, - build_macos: { scheme: 'SCHEME_NAME' }, - }, - isError: false, + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + launch_mac_app: { appPath: '/Applications/MyApp.app' }, + build_macos: { scheme: 'SCHEME_NAME' }, }); }); @@ -143,82 +111,16 @@ describe('get_mac_bundle_id plugin', () => { existsSync: () => true, }); - const result = await get_mac_bundle_idLogic( - { appPath: '/Applications/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result.isError).toBe(true); - expect(result.content).toHaveLength(2); - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Error extracting macOS bundle ID'); - expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist'); - expect(result.content[0].text).toContain('Command failed'); - expect(result.content[1].type).toBe('text'); - expect(result.content[1].text).toBe( - 'Make sure the path points to a valid macOS app bundle (.app directory).', - ); - }); - - it('should handle Error objects in catch blocks', async () => { - const mockExecutor = createMockExecutorForCommands({ - 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error( - 'Custom error message', - ), - '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"': - new Error('Custom error message'), - }); - const mockFileSystemExecutor = createMockFileSystemExecutor({ - existsSync: () => true, - }); - - const result = await get_mac_bundle_idLogic( - { appPath: '/Applications/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result.isError).toBe(true); - expect(result.content).toHaveLength(2); - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Error extracting macOS bundle ID'); - expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist'); - expect(result.content[0].text).toContain('Custom error message'); - expect(result.content[1].type).toBe('text'); - expect(result.content[1].text).toBe( - 'Make sure the path points to a valid macOS app bundle (.app directory).', - ); - }); - - it('should handle string errors in catch blocks', async () => { - const mockExecutor = createMockExecutorForCommands({ - 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error( - 'String error', + const result = await runLogic(() => + get_mac_bundle_idLogic( + { appPath: '/Applications/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, ), - '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"': - new Error('String error'), - }); - const mockFileSystemExecutor = createMockFileSystemExecutor({ - existsSync: () => true, - }); - - const result = await get_mac_bundle_idLogic( - { appPath: '/Applications/MyApp.app' }, - mockExecutor, - mockFileSystemExecutor, ); expect(result.isError).toBe(true); - expect(result.content).toHaveLength(2); - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Error extracting macOS bundle ID'); - expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist'); - expect(result.content[0].text).toContain('String error'); - expect(result.content[1].type).toBe('text'); - expect(result.content[1].text).toBe( - 'Make sure the path points to a valid macOS app bundle (.app directory).', - ); + expect(result.nextStepParams).toBeUndefined(); }); }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts index 3e5b6398..52ff0bc4 100644 --- a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for list_schemes plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -12,6 +6,8 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, listSchemes, listSchemesLogic } from '../list_schemes.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('list_schemes plugin', () => { beforeEach(() => { @@ -36,8 +32,24 @@ describe('list_schemes plugin', () => { }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { - it('should return success with schemes found', async () => { + describe('Handler behavior', () => { + it('returns parsed schemes for setup flows', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: `Information about project "MyProject": + Schemes: + MyProject + MyProjectTests`, + }); + + const schemes = await listSchemes( + { projectPath: '/path/to/MyProject.xcodeproj' }, + mockExecutor, + ); + expect(schemes).toEqual(['MyProject', 'MyProjectTests']); + }); + + it('should return nextStepParams when schemes are found for a project', async () => { const mockExecutor = createMockExecutor({ success: true, output: `Information about project "MyProject": @@ -54,41 +66,24 @@ describe('list_schemes plugin', () => { MyProjectTests`, }); - const result = await listSchemesLogic( - { projectPath: '/path/to/MyProject.xcodeproj' }, - mockExecutor, + const result = await runLogic(() => + listSchemesLogic({ projectPath: '/path/to/MyProject.xcodeproj' }, mockExecutor), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ Available schemes:', - }, - { - type: 'text', - text: 'MyProject\nMyProjectTests', - }, - { - type: 'text', - text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyProject" } to avoid repeating it.', - }, - ], - nextStepParams: { - build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, - build_run_sim: { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyProject', - simulatorName: 'iPhone 17', - }, - build_sim: { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyProject', - simulatorName: 'iPhone 17', - }, - show_build_settings: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, + build_run_sim: { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyProject', + simulatorName: 'iPhone 17', + }, + build_sim: { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyProject', + simulatorName: 'iPhone 17', }, - isError: false, + show_build_settings: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, }); }); @@ -98,32 +93,26 @@ describe('list_schemes plugin', () => { error: 'Project not found', }); - const result = await listSchemesLogic( - { projectPath: '/path/to/MyProject.xcodeproj' }, - mockExecutor, + const result = await runLogic(() => + listSchemesLogic({ projectPath: '/path/to/MyProject.xcodeproj' }, mockExecutor), ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Failed to list schemes: Project not found' }], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); - it('should return error when no schemes found in output', async () => { + it('should return error when no schemes are found in output', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Information about project "MyProject":\n Targets:\n MyProject', }); - const result = await listSchemesLogic( - { projectPath: '/path/to/MyProject.xcodeproj' }, - mockExecutor, + const result = await runLogic(() => + listSchemesLogic({ projectPath: '/path/to/MyProject.xcodeproj' }, mockExecutor), ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'No schemes found in the output' }], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); it('should return success with empty schemes list', async () => { @@ -142,76 +131,29 @@ describe('list_schemes plugin', () => { `, }); - const result = await listSchemesLogic( - { projectPath: '/path/to/MyProject.xcodeproj' }, - mockExecutor, + const result = await runLogic(() => + listSchemesLogic({ projectPath: '/path/to/MyProject.xcodeproj' }, mockExecutor), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ Available schemes:', - }, - { - type: 'text', - text: '', - }, - ], - isError: false, - }); + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toBeUndefined(); }); - it('should handle Error objects in catch blocks', async () => { + it('should handle thrown errors', async () => { const mockExecutor = async () => { throw new Error('Command execution failed'); }; - const result = await listSchemesLogic( - { projectPath: '/path/to/MyProject.xcodeproj' }, - mockExecutor, + const result = await runLogic(() => + listSchemesLogic({ projectPath: '/path/to/MyProject.xcodeproj' }, mockExecutor), ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Error listing schemes: Command execution failed' }], - isError: true, - }); - }); - - it('should handle string error objects in catch blocks', async () => { - const mockExecutor = async () => { - throw 'String error'; - }; - - const result = await listSchemesLogic( - { projectPath: '/path/to/MyProject.xcodeproj' }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [{ type: 'text', text: 'Error listing schemes: String error' }], - isError: true, - }); - }); - - it('returns parsed schemes for setup flows', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: `Information about project "MyProject": - Schemes: - MyProject - MyProjectTests`, - }); - - const schemes = await listSchemes( - { projectPath: '/path/to/MyProject.xcodeproj' }, - mockExecutor, - ); - expect(schemes).toEqual(['MyProject', 'MyProjectTests']); + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); - it('should verify command generation with mock executor', async () => { - const calls: any[] = []; + it('should verify project command generation with mock executor', async () => { + const calls: unknown[][] = []; const mockExecutor = async ( command: string[], action?: string, @@ -237,7 +179,9 @@ describe('list_schemes plugin', () => { }); }; - await listSchemesLogic({ projectPath: '/path/to/MyProject.xcodeproj' }, mockExecutor); + await runLogic(() => + listSchemesLogic({ projectPath: '/path/to/MyProject.xcodeproj' }, mockExecutor), + ); expect(calls).toEqual([ [ @@ -249,9 +193,71 @@ describe('list_schemes plugin', () => { ]); }); + it('should generate correct workspace command', async () => { + const calls: unknown[][] = []; + const mockExecutor = async ( + command: string[], + action?: string, + showOutput?: boolean, + opts?: { cwd?: string }, + detached?: boolean, + ) => { + calls.push([command, action, showOutput, opts?.cwd]); + void detached; + return createMockCommandResponse({ + success: true, + output: `Information about workspace "MyWorkspace": + Schemes: + MyApp`, + error: undefined, + }); + }; + + await runLogic(() => + listSchemesLogic({ workspacePath: '/path/to/MyProject.xcworkspace' }, mockExecutor), + ); + + expect(calls).toEqual([ + [ + ['xcodebuild', '-list', '-workspace', '/path/to/MyProject.xcworkspace'], + 'List Schemes', + false, + undefined, + ], + ]); + }); + + it('should return nextStepParams when schemes are found for a workspace', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: `Information about workspace "MyWorkspace": + Schemes: + MyApp + MyAppTests`, + }); + + const result = await runLogic(() => + listSchemesLogic({ workspacePath: '/path/to/MyProject.xcworkspace' }, mockExecutor), + ); + + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + build_macos: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, + build_run_sim: { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyApp', + simulatorName: 'iPhone 17', + }, + build_sim: { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyApp', + simulatorName: 'iPhone 17', + }, + show_build_settings: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, + }); + }); + it('should handle validation when testing with missing projectPath via plugin handler', async () => { - // Note: Direct logic function calls bypass Zod validation, so we test the actual plugin handler - // to verify Zod validation works properly. The createTypedTool wrapper handles validation. const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -286,85 +292,4 @@ describe('list_schemes plugin', () => { expect(result.content[0].text).toContain('Provide a project or workspace'); }); }); - - describe('Workspace Support', () => { - it('should list schemes for workspace', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: `Information about workspace "MyWorkspace": - Schemes: - MyApp - MyAppTests`, - }); - - const result = await listSchemesLogic( - { workspacePath: '/path/to/MyProject.xcworkspace' }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ Available schemes:', - }, - { - type: 'text', - text: 'MyApp\nMyAppTests', - }, - { - type: 'text', - text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyApp" } to avoid repeating it.', - }, - ], - nextStepParams: { - build_macos: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, - build_run_sim: { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyApp', - simulatorName: 'iPhone 17', - }, - build_sim: { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyApp', - simulatorName: 'iPhone 17', - }, - show_build_settings: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, - }, - isError: false, - }); - }); - - it('should generate correct workspace command', async () => { - const calls: any[] = []; - const mockExecutor = async ( - command: string[], - action?: string, - showOutput?: boolean, - opts?: { cwd?: string }, - detached?: boolean, - ) => { - calls.push([command, action, showOutput, opts?.cwd]); - void detached; - return createMockCommandResponse({ - success: true, - output: `Information about workspace "MyWorkspace": - Schemes: - MyApp`, - error: undefined, - }); - }; - - await listSchemesLogic({ workspacePath: '/path/to/MyProject.xcworkspace' }, mockExecutor); - - expect(calls).toEqual([ - [ - ['xcodebuild', '-list', '-workspace', '/path/to/MyProject.xcworkspace'], - 'List Schemes', - false, - undefined, - ], - ]); - }); - }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts index dd18f402..dff54ccf 100644 --- a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts @@ -3,11 +3,14 @@ import * as z from 'zod'; import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, showBuildSettingsLogic } from '../show_build_settings.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('show_build_settings plugin', () => { beforeEach(() => { sessionStore.clear(); }); + describe('Export Field Validation (Literal)', () => { it('should have handler function', () => { expect(typeof handler).toBe('function'); @@ -22,40 +25,17 @@ describe('show_build_settings plugin', () => { }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { - it('should execute with valid parameters', async () => { + describe('Handler behavior', () => { + it('should return success with build settings and strip preamble', async () => { + const calls: unknown[][] = []; const mockExecutor = createMockExecutor({ success: true, - output: 'Mock build settings output', - error: undefined, - process: { pid: 12345 }, - }); + output: `Command line invocation: + /usr/bin/xcodebuild -showBuildSettings -project /path/to/MyProject.xcodeproj -scheme MyScheme - const result = await showBuildSettingsLogic( - { projectPath: '/valid/path.xcodeproj', scheme: 'MyScheme' }, - mockExecutor, - ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('✅ Build settings for scheme MyScheme:'); - }); +Resolve Package Graph - it('should test Zod validation through handler', async () => { - // Test the actual tool handler which includes Zod validation - const result = await handler({ - projectPath: null, - scheme: 'MyScheme', - }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('Provide a project or workspace'); - }); - - it('should return success with build settings', async () => { - const calls: any[] = []; - const mockExecutor = createMockExecutor({ - success: true, - output: `Build settings from command line: +Build settings for action build and target MyApp: ARCHS = arm64 BUILD_DIR = /Users/dev/Build/Products CONFIGURATION = Debug @@ -67,18 +47,16 @@ describe('show_build_settings plugin', () => { process: { pid: 12345 }, }); - // Wrap mockExecutor to track calls const wrappedExecutor: CommandExecutor = (...args) => { calls.push(args); return mockExecutor(...args); }; - const result = await showBuildSettingsLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - }, - wrappedExecutor, + const result = await runLogic(() => + showBuildSettingsLogic( + { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, + wrappedExecutor, + ), ); expect(calls).toHaveLength(1); @@ -95,34 +73,18 @@ describe('show_build_settings plugin', () => { false, ]); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ Build settings for scheme MyScheme:', - }, - { - type: 'text', - text: `Build settings from command line: - ARCHS = arm64 - BUILD_DIR = /Users/dev/Build/Products - CONFIGURATION = Debug - DEVELOPMENT_TEAM = ABC123DEF4 - PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MyApp - PRODUCT_NAME = MyApp - SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, - }, - ], - nextStepParams: { - build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, - build_sim: { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - simulatorName: 'iPhone 17', - }, - list_schemes: { projectPath: '/path/to/MyProject.xcodeproj' }, + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('Build settings for action build and target MyApp:'); + expect(text).toContain('PRODUCT_NAME = MyApp'); + expect(result.nextStepParams).toEqual({ + build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, + build_sim: { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + simulatorName: 'iPhone 17', }, - isError: false, + list_schemes: { projectPath: '/path/to/MyProject.xcodeproj' }, }); }); @@ -130,41 +92,36 @@ describe('show_build_settings plugin', () => { const mockExecutor = createMockExecutor({ success: false, output: '', - error: 'Scheme not found', + error: + 'xcodebuild: error: The workspace named "App" does not contain a scheme named "InvalidScheme".', process: { pid: 12345 }, }); - const result = await showBuildSettingsLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'InvalidScheme', - }, - mockExecutor, + const result = await runLogic(() => + showBuildSettingsLogic( + { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'InvalidScheme' }, + mockExecutor, + ), ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Failed to show build settings: Scheme not found' }], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); - it('should handle Error objects in catch blocks', async () => { + it('should handle thrown errors', async () => { const mockExecutor = async () => { throw new Error('Command execution failed'); }; - const result = await showBuildSettingsLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, + const result = await runLogic(() => + showBuildSettingsLogic( + { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, + mockExecutor, + ), ); - expect(result).toEqual({ - content: [{ type: 'text', text: 'Error showing build settings: Command execution failed' }], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); }); @@ -189,43 +146,13 @@ describe('show_build_settings plugin', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); }); - - it('should work with projectPath only', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Mock build settings output', - }); - - const result = await showBuildSettingsLogic( - { projectPath: '/valid/path.xcodeproj', scheme: 'MyScheme' }, - mockExecutor, - ); - - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('✅ Build settings for scheme MyScheme:'); - }); - - it('should work with workspacePath only', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Mock build settings output', - }); - - const result = await showBuildSettingsLogic( - { workspacePath: '/valid/path.xcworkspace', scheme: 'MyScheme' }, - mockExecutor, - ); - - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('✅ Build settings retrieved successfully'); - }); }); describe('Session requirement handling', () => { it('should require scheme when not provided', async () => { const result = await handler({ projectPath: '/path/to/MyProject.xcodeproj', - } as any); + } as never); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -242,122 +169,4 @@ describe('show_build_settings plugin', () => { expect(result.content[0].text).toContain('Provide a project or workspace'); }); }); - - describe('showBuildSettingsLogic function', () => { - it('should return success with build settings', async () => { - const calls: any[] = []; - const mockExecutor = createMockExecutor({ - success: true, - output: `Build settings from command line: - ARCHS = arm64 - BUILD_DIR = /Users/dev/Build/Products - CONFIGURATION = Debug - DEVELOPMENT_TEAM = ABC123DEF4 - PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MyApp - PRODUCT_NAME = MyApp - SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, - error: undefined, - process: { pid: 12345 }, - }); - - // Wrap mockExecutor to track calls - const wrappedExecutor: CommandExecutor = (...args) => { - calls.push(args); - return mockExecutor(...args); - }; - - const result = await showBuildSettingsLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - }, - wrappedExecutor, - ); - - expect(calls).toHaveLength(1); - expect(calls[0]).toEqual([ - [ - 'xcodebuild', - '-showBuildSettings', - '-project', - '/path/to/MyProject.xcodeproj', - '-scheme', - 'MyScheme', - ], - 'Show Build Settings', - false, - ]); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ Build settings for scheme MyScheme:', - }, - { - type: 'text', - text: `Build settings from command line: - ARCHS = arm64 - BUILD_DIR = /Users/dev/Build/Products - CONFIGURATION = Debug - DEVELOPMENT_TEAM = ABC123DEF4 - PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MyApp - PRODUCT_NAME = MyApp - SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, - }, - ], - nextStepParams: { - build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, - build_sim: { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - simulatorName: 'iPhone 17', - }, - list_schemes: { projectPath: '/path/to/MyProject.xcodeproj' }, - }, - isError: false, - }); - }); - - it('should return error when command fails', async () => { - const mockExecutor = createMockExecutor({ - success: false, - output: '', - error: 'Scheme not found', - process: { pid: 12345 }, - }); - - const result = await showBuildSettingsLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'InvalidScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [{ type: 'text', text: 'Failed to show build settings: Scheme not found' }], - isError: true, - }); - }); - - it('should handle Error objects in catch blocks', async () => { - const mockExecutor = async () => { - throw new Error('Command execution failed'); - }; - - const result = await showBuildSettingsLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [{ type: 'text', text: 'Error showing build settings: Command execution failed' }], - isError: true, - }); - }); - }); }); diff --git a/src/mcp/tools/project-discovery/discover_projs.ts b/src/mcp/tools/project-discovery/discover_projs.ts index c6c3c70b..091af570 100644 --- a/src/mcp/tools/project-discovery/discover_projs.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -8,17 +8,14 @@ import * as z from 'zod'; import * as path from 'node:path'; import { log } from '../../../utils/logging/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Constants -const DEFAULT_MAX_DEPTH = 5; +const DEFAULT_MAX_DEPTH = 3; const SKIPPED_DIRS = new Set(['build', 'DerivedData', 'Pods', '.git', 'node_modules']); -// Type definition for Dirent-like objects returned by readdir with withFileTypes: true interface DirentLike { name: string; isDirectory(): boolean; @@ -30,11 +27,8 @@ function getErrorDetails( fallbackMessage: string, ): { code?: string; message: string } { if (error instanceof Error) { - const errorWithCode = error as Error & { code?: unknown }; - return { - code: typeof errorWithCode.code === 'string' ? errorWithCode.code : undefined, - message: error.message, - }; + const nodeError = error as NodeJS.ErrnoException; + return { code: nodeError.code, message: error.message }; } if (typeof error === 'object' && error !== null) { @@ -59,7 +53,6 @@ async function _findProjectsRecursive( results: { projects: string[]; workspaces: string[] }, fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise { - // Explicit depth check (now simplified as maxDepth is always non-negative) if (currentDepth >= maxDepth) { log('debug', `Max depth ${maxDepth} reached at ${currentDirAbs}, stopping recursion.`); return; @@ -69,27 +62,22 @@ async function _findProjectsRecursive( const normalizedWorkspaceRoot = path.normalize(workspaceRootAbs); try { - // Use the injected fileSystemExecutor const entries = await fileSystemExecutor.readdir(currentDirAbs, { withFileTypes: true }); for (const rawEntry of entries) { - // Cast the unknown entry to DirentLike interface for type safety const entry = rawEntry as DirentLike; const absoluteEntryPath = path.join(currentDirAbs, entry.name); const relativePath = path.relative(workspaceRootAbs, absoluteEntryPath); - // --- Skip conditions --- if (entry.isSymbolicLink()) { log('debug', `Skipping symbolic link: ${relativePath}`); continue; } - // Skip common build/dependency directories by name if (entry.isDirectory() && SKIPPED_DIRS.has(entry.name)) { log('debug', `Skipping standard directory: ${relativePath}`); continue; } - // Ensure entry is within the workspace root (security/sanity check) if (!path.normalize(absoluteEntryPath).startsWith(normalizedWorkspaceRoot)) { log( 'warn', @@ -98,21 +86,19 @@ async function _findProjectsRecursive( continue; } - // --- Process entries --- if (entry.isDirectory()) { let isXcodeBundle = false; if (entry.name.endsWith('.xcodeproj')) { - results.projects.push(absoluteEntryPath); // Use absolute path + results.projects.push(absoluteEntryPath); log('debug', `Found project: ${absoluteEntryPath}`); isXcodeBundle = true; } else if (entry.name.endsWith('.xcworkspace')) { - results.workspaces.push(absoluteEntryPath); // Use absolute path + results.workspaces.push(absoluteEntryPath); log('debug', `Found workspace: ${absoluteEntryPath}`); isXcodeBundle = true; } - // Recurse into regular directories, but not into found project/workspace bundles if (!isXcodeBundle) { await _findProjectsRecursive( absoluteEntryPath, @@ -136,7 +122,6 @@ async function _findProjectsRecursive( } } -// Define schema as ZodObject const discoverProjsSchema = z.object({ workspaceRoot: z.string(), scanPath: z.string().optional(), @@ -154,20 +139,42 @@ export interface DiscoverProjectsResult { workspaces: string[]; } -// Use z.infer for type safety type DiscoverProjsParams = z.infer; +function isBundleLikePath(workspaceRoot: string): boolean { + return ( + workspaceRoot.endsWith('.app') || + workspaceRoot.endsWith('.xcworkspace') || + workspaceRoot.endsWith('.xcodeproj') + ); +} + +function resolveScanBase(workspaceRoot: string, scanPath?: string): string { + if (scanPath) { + return scanPath; + } + + if (isBundleLikePath(workspaceRoot)) { + return path.dirname(workspaceRoot); + } + + return '.'; +} + async function discoverProjectsOrError( params: DiscoverProjectsParams, fileSystemExecutor: FileSystemExecutor, ): Promise { - const scanPath = params.scanPath ?? '.'; + const scanPath = resolveScanBase(params.workspaceRoot, params.scanPath); const maxDepth = params.maxDepth ?? DEFAULT_MAX_DEPTH; const workspaceRoot = params.workspaceRoot; const requestedScanPath = path.resolve(workspaceRoot, scanPath); let absoluteScanPath = requestedScanPath; - const normalizedWorkspaceRoot = path.normalize(workspaceRoot); + const workspaceBoundary = isBundleLikePath(workspaceRoot) + ? path.dirname(workspaceRoot) + : workspaceRoot; + const normalizedWorkspaceRoot = path.normalize(workspaceBoundary); if (!path.normalize(absoluteScanPath).startsWith(normalizedWorkspaceRoot)) { log( 'warn', @@ -228,13 +235,23 @@ export async function discoverProjects( export async function discover_projsLogic( params: DiscoverProjsParams, fileSystemExecutor: FileSystemExecutor, -): Promise { +): Promise { + const ctx = getHandlerContext(); + const scanPath = resolveScanBase(params.workspaceRoot, params.scanPath); + const maxDepth = params.maxDepth ?? DEFAULT_MAX_DEPTH; + const resolvedWorkspaceRoot = path.resolve(params.workspaceRoot); + const resolvedScanPath = path.resolve(params.workspaceRoot, scanPath); + + const headerEvent = header('Discover Projects', [ + { label: 'Workspace root', value: resolvedWorkspaceRoot }, + { label: 'Scan path', value: resolvedScanPath }, + { label: 'Max depth', value: String(maxDepth) }, + ]); const results = await discoverProjectsOrError(params, fileSystemExecutor); if ('error' in results) { - return { - content: [createTextContent(results.error)], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit(statusLine('error', results.error)); + return; } log( @@ -242,44 +259,35 @@ export async function discover_projsLogic( `Discovery finished. Found ${results.projects.length} projects and ${results.workspaces.length} workspaces.`, ); - const responseContent = [ - createTextContent( - `Discovery finished. Found ${results.projects.length} projects and ${results.workspaces.length} workspaces.`, + const projectWord = results.projects.length === 1 ? 'project' : 'projects'; + const workspaceWord = results.workspaces.length === 1 ? 'workspace' : 'workspaces'; + + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'success', + `Found ${results.projects.length} ${projectWord} and ${results.workspaces.length} ${workspaceWord}`, ), - ]; + ); - if (results.projects.length > 0) { - responseContent.push( - createTextContent(`Projects found:\n - ${results.projects.join('\n - ')}`), - ); + const cwd = process.cwd(); + function toRelative(p: string): string { + return path.relative(cwd, p) || p; } - if (results.workspaces.length > 0) { - responseContent.push( - createTextContent(`Workspaces found:\n - ${results.workspaces.join('\n - ')}`), - ); + if (results.projects.length > 0) { + ctx.emit(section('Projects:', results.projects.map(toRelative))); } - if (results.projects.length > 0 || results.workspaces.length > 0) { - responseContent.push( - createTextContent( - "Hint: Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }.", - ), - ); + if (results.workspaces.length > 0) { + ctx.emit(section('Workspaces:', results.workspaces.map(toRelative))); } - - return { - content: responseContent, - isError: false, - }; } export const schema = discoverProjsSchema.shape; export const handler = createTypedTool( discoverProjsSchema, - (params: DiscoverProjsParams) => { - return discover_projsLogic(params, getDefaultFileSystemExecutor()); - }, + (params: DiscoverProjsParams) => discover_projsLogic(params, getDefaultFileSystemExecutor()), getDefaultCommandExecutor, ); diff --git a/src/mcp/tools/project-discovery/get_app_bundle_id.ts b/src/mcp/tools/project-discovery/get_app_bundle_id.ts index 60c81738..cafb942b 100644 --- a/src/mcp/tools/project-discovery/get_app_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_app_bundle_id.ts @@ -7,19 +7,18 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/command.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; import { extractBundleIdFromAppPath } from '../../../utils/bundle-id.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const getAppBundleIdSchema = z.object({ appPath: z.string().describe('Path to the .app bundle'), }); -// Use z.infer for type safety type GetAppBundleIdParams = z.infer; /** @@ -30,70 +29,56 @@ export async function get_app_bundle_idLogic( params: GetAppBundleIdParams, executor: CommandExecutor, fileSystemExecutor: FileSystemExecutor, -): Promise { - // Zod validation is handled by createTypedTool, so params.appPath is guaranteed to be a string +): Promise { const appPath = params.appPath; + const headerEvent = header('Get Bundle ID', [{ label: 'App', value: appPath }]); if (!fileSystemExecutor.existsSync(appPath)) { - return { - content: [ - { - type: 'text', - text: `File not found: '${appPath}'. Please check the path and try again.`, - }, - ], - isError: true, - }; + const ctx = getHandlerContext(); + ctx.emit(headerEvent); + ctx.emit( + statusLine('error', `File not found: '${appPath}'. Please check the path and try again.`), + ); + return; } log('info', `Starting bundle ID extraction for app: ${appPath}`); - try { - let bundleId; + const ctx = getHandlerContext(); - try { - bundleId = await extractBundleIdFromAppPath(appPath, executor); - } catch (innerError) { - throw new Error( - `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, - ); - } + return withErrorHandling( + ctx, + async () => { + const bundleId = await extractBundleIdFromAppPath(appPath, executor).catch((innerError) => { + throw new Error( + `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, + ); + }); - log('info', `Extracted app bundle ID: ${bundleId}`); + log('info', `Extracted app bundle ID: ${bundleId}`); - return { - content: [ - { - type: 'text', - text: `✅ Bundle ID: ${bundleId}`, - }, - ], - nextStepParams: { + ctx.emit(headerEvent); + ctx.emit(statusLine('success', `Bundle ID\n \u2514 ${bundleId.trim()}`)); + ctx.nextStepParams = { install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: bundleId.trim() }, install_app_device: { deviceId: 'DEVICE_UDID', appPath }, launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: bundleId.trim() }, + }; + }, + { + header: headerEvent, + errorMessage: ({ message }) => message, + logMessage: ({ message }) => `Error extracting app bundle ID: ${message}`, + mapError: ({ message, headerEvent: hdr, emit }) => { + emit?.(hdr); + emit?.(statusLine('error', message)); + emit?.( + statusLine('info', 'Make sure the path points to a valid app bundle (.app directory).'), + ); }, - isError: false, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error extracting app bundle ID: ${errorMessage}`); - - return { - content: [ - { - type: 'text', - text: `Error extracting app bundle ID: ${errorMessage}`, - }, - { - type: 'text', - text: `Make sure the path points to a valid app bundle (.app directory).`, - }, - ], - isError: true, - }; - } + }, + ); } export const schema = getAppBundleIdSchema.shape; diff --git a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts index e396c986..23201400 100644 --- a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts @@ -1,20 +1,12 @@ -/** - * Project Discovery Plugin: Get macOS Bundle ID - * - * Extracts the bundle identifier from a macOS app bundle (.app). - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/command.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -/** - * Sync wrapper for CommandExecutor to handle synchronous commands - */ async function executeSyncCommand(command: string, executor: CommandExecutor): Promise { const result = await executor(['/bin/sh', '-c', command], 'macOS Bundle ID Extraction'); if (!result.success) { @@ -23,92 +15,81 @@ async function executeSyncCommand(command: string, executor: CommandExecutor): P return result.output || ''; } -// Define schema as ZodObject const getMacBundleIdSchema = z.object({ appPath: z.string().describe('Path to the .app bundle'), }); -// Use z.infer for type safety type GetMacBundleIdParams = z.infer; -/** - * Business logic for extracting macOS bundle ID - */ export async function get_mac_bundle_idLogic( params: GetMacBundleIdParams, executor: CommandExecutor, fileSystemExecutor: FileSystemExecutor, -): Promise { +): Promise { const appPath = params.appPath; + const headerEvent = header('Get macOS Bundle ID', [{ label: 'App', value: appPath }]); if (!fileSystemExecutor.existsSync(appPath)) { - return { - content: [ - { - type: 'text', - text: `File not found: '${appPath}'. Please check the path and try again.`, - }, - ], - isError: true, - }; + const ctx = getHandlerContext(); + ctx.emit(headerEvent); + ctx.emit( + statusLine('error', `File not found: '${appPath}'. Please check the path and try again.`), + ); + return; } log('info', `Starting bundle ID extraction for macOS app: ${appPath}`); - try { - let bundleId; + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + let bundleId; - try { - bundleId = await executeSyncCommand( - `defaults read "${appPath}/Contents/Info" CFBundleIdentifier`, - executor, - ); - } catch { try { bundleId = await executeSyncCommand( - `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Contents/Info.plist"`, + `defaults read "${appPath}/Contents/Info" CFBundleIdentifier`, executor, ); - } catch (innerError) { - throw new Error( - `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, - ); + } catch { + try { + bundleId = await executeSyncCommand( + `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Contents/Info.plist"`, + executor, + ); + } catch (innerError) { + throw new Error( + `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, + ); + } } - } - log('info', `Extracted macOS bundle ID: ${bundleId}`); + log('info', `Extracted macOS bundle ID: ${bundleId}`); - return { - content: [ - { - type: 'text', - text: `✅ Bundle ID: ${bundleId}`, - }, - ], - nextStepParams: { + ctx.emit(headerEvent); + ctx.emit(statusLine('success', `Bundle ID\n \u2514 ${bundleId.trim()}`)); + ctx.nextStepParams = { launch_mac_app: { appPath }, build_macos: { scheme: 'SCHEME_NAME' }, + }; + }, + { + header: headerEvent, + errorMessage: ({ message }) => message, + logMessage: ({ message }) => `Error extracting macOS bundle ID: ${message}`, + mapError: ({ message, headerEvent: hdr, emit }) => { + emit?.(hdr); + emit?.(statusLine('error', message)); + emit?.( + statusLine( + 'info', + 'Make sure the path points to a valid macOS app bundle (.app directory).', + ), + ); }, - isError: false, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error extracting macOS bundle ID: ${errorMessage}`); - - return { - content: [ - { - type: 'text', - text: `Error extracting macOS bundle ID: ${errorMessage}`, - }, - { - type: 'text', - text: `Make sure the path points to a valid macOS app bundle (.app directory).`, - }, - ], - isError: true, - }; - } + }, + ); } export const schema = getMacBundleIdSchema.shape; diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts index 8e06940a..657d6b68 100644 --- a/src/mcp/tools/project-discovery/list_schemes.ts +++ b/src/mcp/tools/project-discovery/list_schemes.ts @@ -1,23 +1,16 @@ -/** - * Project Discovery Plugin: List Schemes (Unified) - * - * Lists available schemes for either a project or workspace using xcodebuild. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -36,8 +29,6 @@ const listSchemesSchema = z.preprocess( export type ListSchemesParams = z.infer; -const createTextBlock = (text: string) => ({ type: 'text', text }) as const; - export function parseSchemesFromXcodebuildListOutput(output: string): string[] { const schemesMatch = output.match(/Schemes:([\s\S]*?)(?=\n\n|$)/); if (!schemesMatch) { @@ -71,70 +62,67 @@ export async function listSchemes( return parseSchemesFromXcodebuildListOutput(result.output); } -/** - * Business logic for listing schemes in a project or workspace. - * Exported for direct testing and reuse. - */ export async function listSchemesLogic( params: ListSchemesParams, executor: CommandExecutor, -): Promise { +): Promise { log('info', 'Listing schemes'); - try { - const hasProjectPath = typeof params.projectPath === 'string'; - const projectOrWorkspace = hasProjectPath ? 'project' : 'workspace'; - const path = hasProjectPath ? params.projectPath : params.workspacePath; - const schemes = await listSchemes(params, executor); - - let nextStepParams: Record> | undefined; - let hintText = ''; - - if (schemes.length > 0) { - const firstScheme = schemes[0]; - - nextStepParams = { - build_macos: { [`${projectOrWorkspace}Path`]: path!, scheme: firstScheme }, - build_run_sim: { - [`${projectOrWorkspace}Path`]: path!, - scheme: firstScheme, - simulatorName: 'iPhone 17', - }, - build_sim: { - [`${projectOrWorkspace}Path`]: path!, - scheme: firstScheme, - simulatorName: 'iPhone 17', - }, - show_build_settings: { [`${projectOrWorkspace}Path`]: path!, scheme: firstScheme }, - }; - - hintText = - `Hint: Consider saving a default scheme with session-set-defaults ` + - `{ scheme: "${firstScheme}" } to avoid repeating it.`; - } - - const content = [createTextBlock('✅ Available schemes:'), createTextBlock(schemes.join('\n'))]; - if (hintText.length > 0) { - content.push(createTextBlock(hintText)); - } - - return { - content, - ...(nextStepParams ? { nextStepParams } : {}), - isError: false, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if ( - errorMessage.startsWith('Failed to list schemes:') || - errorMessage === 'No schemes found in the output' - ) { - return createTextResponse(errorMessage, true); - } - - log('error', `Error listing schemes: ${errorMessage}`); - return createTextResponse(`Error listing schemes: ${errorMessage}`, true); - } + const hasProjectPath = typeof params.projectPath === 'string'; + const projectOrWorkspace = hasProjectPath ? 'project' : 'workspace'; + const pathValue = hasProjectPath ? params.projectPath : params.workspacePath; + + const headerEvent = header( + 'List Schemes', + hasProjectPath + ? [{ label: 'Project', value: pathValue! }] + : [{ label: 'Workspace', value: pathValue! }], + ); + + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + const schemes = await listSchemes(params, executor); + + if (schemes.length > 0) { + const firstScheme = schemes[0]; + + ctx.nextStepParams = { + build_macos: { [`${projectOrWorkspace}Path`]: pathValue!, scheme: firstScheme }, + build_run_sim: { + [`${projectOrWorkspace}Path`]: pathValue!, + scheme: firstScheme, + simulatorName: 'iPhone 17', + }, + build_sim: { + [`${projectOrWorkspace}Path`]: pathValue!, + scheme: firstScheme, + simulatorName: 'iPhone 17', + }, + show_build_settings: { [`${projectOrWorkspace}Path`]: pathValue!, scheme: firstScheme }, + }; + } + + const schemeItems = schemes.length > 0 ? schemes : ['(none)']; + const schemeWord = schemes.length === 1 ? 'scheme' : 'schemes'; + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', `Found ${schemes.length} ${schemeWord}`)); + ctx.emit(section('Schemes:', schemeItems)); + }, + { + header: headerEvent, + errorMessage: ({ message }) => { + const rawError = message.startsWith('Failed to list schemes: ') + ? message.slice('Failed to list schemes: '.length) + : message; + return rawError; + }, + logMessage: ({ message }) => `Error listing schemes: ${message}`, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/project-discovery/show_build_settings.ts b/src/mcp/tools/project-discovery/show_build_settings.ts index 015f30b7..32782d23 100644 --- a/src/mcp/tools/project-discovery/show_build_settings.ts +++ b/src/mcp/tools/project-discovery/show_build_settings.ts @@ -1,23 +1,16 @@ -/** - * Project Discovery Plugin: Show Build Settings (Unified) - * - * Shows build settings from either a project or workspace using xcodebuild. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -37,75 +30,77 @@ const showBuildSettingsSchema = z.preprocess( export type ShowBuildSettingsParams = z.infer; -/** - * Business logic for showing build settings from a project or workspace. - * Exported for direct testing and reuse. - */ +function stripXcodebuildPreamble(output: string): string { + const lines = output.split('\n'); + const startIndex = lines.findIndex((line) => line.startsWith('Build settings for action')); + if (startIndex === -1) { + return output; + } + return lines.slice(startIndex).join('\n'); +} + export async function showBuildSettingsLogic( params: ShowBuildSettingsParams, executor: CommandExecutor, -): Promise { +): Promise { log('info', `Showing build settings for scheme ${params.scheme}`); - try { - // Create the command array for xcodebuild - const command = ['xcodebuild', '-showBuildSettings']; // -showBuildSettings as an option, not an action - - const hasProjectPath = typeof params.projectPath === 'string'; - const path = hasProjectPath ? params.projectPath : params.workspacePath; - - if (hasProjectPath) { - command.push('-project', params.projectPath!); - } else { - command.push('-workspace', params.workspacePath!); - } - - // Add the scheme - command.push('-scheme', params.scheme); - - // Execute the command directly - const result = await executor(command, 'Show Build Settings', false); - - if (!result.success) { - return createTextResponse(`Failed to show build settings: ${result.error}`, true); - } - - // Create response based on which type was used - const content: Array<{ type: 'text'; text: string }> = [ - { - type: 'text', - text: hasProjectPath - ? `✅ Build settings for scheme ${params.scheme}:` - : '✅ Build settings retrieved successfully', - }, - { - type: 'text', - text: result.output || 'Build settings retrieved successfully.', - }, - ]; - - // Build next step params - let nextStepParams: Record> | undefined; - - if (path) { + const hasProjectPath = typeof params.projectPath === 'string'; + const pathValue = hasProjectPath ? params.projectPath : params.workspacePath; + + const headerEvent = header('Show Build Settings', [ + { label: 'Scheme', value: params.scheme }, + ...(hasProjectPath + ? [{ label: 'Project', value: params.projectPath! }] + : [{ label: 'Workspace', value: params.workspacePath! }]), + ]); + + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + const command = ['xcodebuild', '-showBuildSettings']; + + if (hasProjectPath) { + command.push('-project', params.projectPath!); + } else { + command.push('-workspace', params.workspacePath!); + } + + command.push('-scheme', params.scheme); + + const result = await executor(command, 'Show Build Settings', false); + + if (!result.success) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', result.error || 'Unknown error')); + return; + } + + const settingsOutput = stripXcodebuildPreamble( + result.output || 'Build settings retrieved successfully.', + ); + const pathKey = hasProjectPath ? 'projectPath' : 'workspacePath'; - nextStepParams = { - build_macos: { [pathKey]: path, scheme: params.scheme }, - build_sim: { [pathKey]: path, scheme: params.scheme, simulatorName: 'iPhone 17' }, - list_schemes: { [pathKey]: path }, + ctx.nextStepParams = { + build_macos: { [pathKey]: pathValue!, scheme: params.scheme }, + build_sim: { [pathKey]: pathValue!, scheme: params.scheme, simulatorName: 'iPhone 17' }, + list_schemes: { [pathKey]: pathValue! }, }; - } - - return { - content, - ...(nextStepParams ? { nextStepParams } : {}), - isError: false, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error showing build settings: ${errorMessage}`); - return createTextResponse(`Error showing build settings: ${errorMessage}`, true); - } + + const settingsLines = settingsOutput.split('\n').filter((l) => l.trim()); + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Build settings retrieved')); + ctx.emit(section('Settings', settingsLines)); + }, + { + header: headerEvent, + errorMessage: ({ message }) => message, + logMessage: ({ message }) => `Error showing build settings: ${message}`, + }, + ); } const publicSchemaObject = baseSchemaObject.omit({ diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts index 7707c407..fec22965 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts @@ -1,12 +1,3 @@ -/** - * Vitest test for scaffold_ios_project plugin - * - * Tests the plugin structure and iOS scaffold tool functionality - * including parameter validation, file operations, template processing, and response formatting. - * - * Plugin location: plugins/utilities/scaffold_ios_project.js - */ - import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as z from 'zod'; import { schema, handler, scaffold_ios_projectLogic } from '../scaffold_ios_project.ts'; @@ -19,6 +10,8 @@ import { initConfigStore, type RuntimeConfigOverrides, } from '../../../../utils/config-store.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + const cwd = '/repo'; @@ -32,7 +25,6 @@ describe('scaffold_ios_project plugin', () => { let mockFileSystemExecutor: any; beforeEach(async () => { - // Create mock executor using approved utility mockCommandExecutor = createMockExecutor({ success: true, output: 'Command executed successfully', @@ -40,7 +32,6 @@ describe('scaffold_ios_project plugin', () => { mockFileSystemExecutor = createMockFileSystemExecutor({ existsSync: (path) => { - // Mock template directories exist but project files don't return ( path.includes('xcodebuild-mcp-template') || path.includes('XcodeBuildMCP-iOS-Template') || @@ -73,7 +64,6 @@ describe('scaffold_ios_project plugin', () => { it('should have valid schema with required fields', () => { const schemaObj = z.object(schema); - // Test valid input expect( schemaObj.safeParse({ projectName: 'MyTestApp', @@ -90,7 +80,6 @@ describe('scaffold_ios_project plugin', () => { }).success, ).toBe(true); - // Test minimal valid input expect( schemaObj.safeParse({ projectName: 'MyTestApp', @@ -98,21 +87,18 @@ describe('scaffold_ios_project plugin', () => { }).success, ).toBe(true); - // Test invalid input - missing projectName expect( schemaObj.safeParse({ outputPath: '/path/to/output', }).success, ).toBe(false); - // Test invalid input - missing outputPath expect( schemaObj.safeParse({ projectName: 'MyTestApp', }).success, ).toBe(false); - // Test invalid input - wrong type for customizeNames expect( schemaObj.safeParse({ projectName: 'MyTestApp', @@ -121,7 +107,6 @@ describe('scaffold_ios_project plugin', () => { }).success, ).toBe(false); - // Test invalid input - wrong enum value for targetedDeviceFamily expect( schemaObj.safeParse({ projectName: 'MyTestApp', @@ -130,7 +115,6 @@ describe('scaffold_ios_project plugin', () => { }).success, ).toBe(false); - // Test invalid input - wrong enum value for supportedOrientations expect( schemaObj.safeParse({ projectName: 'MyTestApp', @@ -145,29 +129,28 @@ describe('scaffold_ios_project plugin', () => { it('should generate correct curl command for iOS template download', async () => { await initConfigStoreForTest({ iosTemplatePath: '' }); - // Track commands executed let capturedCommands: string[][] = []; const trackingCommandExecutor = createMockExecutor({ success: true, output: 'Command executed successfully', }); - // Wrap to capture commands const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); return trackingCommandExecutor(command, ...args); }; - await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - capturingExecutor, - mockFileSystemExecutor, + await runLogic(() => + scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + customizeNames: true, + outputPath: '/tmp/test-projects', + }, + capturingExecutor, + mockFileSystemExecutor, + ), ); - // Verify curl command was executed const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); expect(curlCommand).toBeDefined(); expect(curlCommand).toEqual([ @@ -184,87 +167,31 @@ describe('scaffold_ios_project plugin', () => { await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); - it.skip('should generate correct unzip command for iOS template extraction', async () => { - await initConfigStoreForTest({ iosTemplatePath: '' }); - - // Create a mock that returns false for local template paths to force download - const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ - existsSync: (path) => { - // Only return true for extracted template directories, false for local template paths - return ( - path.includes('xcodebuild-mcp-template') || - path.includes('XcodeBuildMCP-iOS-Template') || - path.includes('extracted') - ); - }, - readFile: async () => 'template content with MyProject placeholder', - readdir: async () => [ - { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, - { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, - ], - mkdir: async () => {}, - rm: async () => {}, - cp: async () => {}, - writeFile: async () => {}, - stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), - }); - - // Track commands executed - let capturedCommands: string[][] = []; - const trackingCommandExecutor = createMockExecutor({ - success: true, - output: 'Command executed successfully', - }); - // Wrap to capture commands - const capturingExecutor = async (command: string[], ...args: any[]) => { - capturedCommands.push(command); - return trackingCommandExecutor(command, ...args); - }; - - await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - capturingExecutor, - downloadMockFileSystemExecutor, - ); - - // Verify unzip command was executed - const unzipCommand = capturedCommands.find((cmd) => cmd.includes('unzip')); - expect(unzipCommand).toBeDefined(); - expect(unzipCommand).toEqual(['unzip', '-q', expect.stringMatching(/template\.zip$/)]); - - await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); - }); - it('should generate correct commands when using custom template version', async () => { await initConfigStoreForTest({ iosTemplatePath: '', iosTemplateVersion: 'v2.0.0' }); - // Track commands executed let capturedCommands: string[][] = []; const trackingCommandExecutor = createMockExecutor({ success: true, output: 'Command executed successfully', }); - // Wrap to capture commands const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); return trackingCommandExecutor(command, ...args); }; - await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - capturingExecutor, - mockFileSystemExecutor, + await runLogic(() => + scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + customizeNames: true, + outputPath: '/tmp/test-projects', + }, + capturingExecutor, + mockFileSystemExecutor, + ), ); - // Verify curl command uses custom version const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); expect(curlCommand).toBeDefined(); expect(curlCommand).toEqual([ @@ -278,240 +205,130 @@ describe('scaffold_ios_project plugin', () => { await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); - - it.skip('should generate correct commands with no command executor passed', async () => { - await initConfigStoreForTest({ iosTemplatePath: '' }); - - // Create a mock that returns false for local template paths to force download - const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ - existsSync: (path) => { - // Only return true for extracted template directories, false for local template paths - return ( - path.includes('xcodebuild-mcp-template') || - path.includes('XcodeBuildMCP-iOS-Template') || - path.includes('extracted') - ); - }, - readFile: async () => 'template content with MyProject placeholder', - readdir: async () => [ - { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, - { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, - ], - mkdir: async () => {}, - rm: async () => {}, - cp: async () => {}, - writeFile: async () => {}, - stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), - }); - - // Track commands executed - using default executor path - let capturedCommands: string[][] = []; - const trackingCommandExecutor = createMockExecutor({ - success: true, - output: 'Command executed successfully', - }); - // Wrap to capture commands - const capturingExecutor = async (command: string[], ...args: any[]) => { - capturedCommands.push(command); - return trackingCommandExecutor(command, ...args); - }; - - await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - capturingExecutor, - downloadMockFileSystemExecutor, - ); - - // Verify both curl and unzip commands were executed in sequence - expect(capturedCommands.length).toBeGreaterThanOrEqual(2); - - const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); - const unzipCommand = capturedCommands.find((cmd) => cmd.includes('unzip')); - - expect(curlCommand).toBeDefined(); - expect(unzipCommand).toBeDefined(); - if (!curlCommand || !unzipCommand) { - throw new Error('Expected curl and unzip commands to be captured'); - } - expect(curlCommand[0]).toBe('curl'); - expect(unzipCommand[0]).toBe('unzip'); - - await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); - }); }); describe('Handler Behavior (Complete Literal Returns)', () => { it('should return success response for valid scaffold iOS project request', async () => { - const result = await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - bundleIdentifier: 'com.test.iosapp', - }, - mockCommandExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + scaffold_ios_projectLogic( { - type: 'text', - text: JSON.stringify( - { - success: true, - projectPath: '/tmp/test-projects', - platform: 'iOS', - message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - nextStepParams: { - build_sim: { - workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', - scheme: 'TestIOSApp', - simulatorName: 'iPhone 17', - }, - build_run_sim: { - workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', - scheme: 'TestIOSApp', - simulatorName: 'iPhone 17', + projectName: 'TestIOSApp', + customizeNames: true, + outputPath: '/tmp/test-projects', + bundleIdentifier: 'com.test.iosapp', }, + mockCommandExecutor, + mockFileSystemExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('Scaffold iOS Project'); + expect(text).toContain('TestIOSApp'); + expect(text).toContain('/tmp/test-projects'); + expect(text).toContain('Project scaffolded successfully'); + expect(result.nextStepParams).toEqual({ + build_sim: { + workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', + scheme: 'TestIOSApp', + simulatorName: 'iPhone 17', + }, + build_run_sim: { + workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', + scheme: 'TestIOSApp', + simulatorName: 'iPhone 17', }, }); }); it('should return success response with all optional parameters', async () => { - const result = await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - bundleIdentifier: 'com.test.iosapp', - displayName: 'Test iOS App', - marketingVersion: '2.0', - currentProjectVersion: '5', - deploymentTarget: '17.0', - targetedDeviceFamily: ['iphone'], - supportedOrientations: ['portrait'], - supportedOrientationsIpad: ['portrait', 'landscape-left'], - }, - mockCommandExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + scaffold_ios_projectLogic( { - type: 'text', - text: JSON.stringify( - { - success: true, - projectPath: '/tmp/test-projects', - platform: 'iOS', - message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - nextStepParams: { - build_sim: { - workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', - scheme: 'TestIOSApp', - simulatorName: 'iPhone 17', - }, - build_run_sim: { - workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', - scheme: 'TestIOSApp', - simulatorName: 'iPhone 17', + projectName: 'TestIOSApp', + customizeNames: true, + outputPath: '/tmp/test-projects', + bundleIdentifier: 'com.test.iosapp', + displayName: 'Test iOS App', + marketingVersion: '2.0', + currentProjectVersion: '5', + deploymentTarget: '17.0', + targetedDeviceFamily: ['iphone'], + supportedOrientations: ['portrait'], + supportedOrientationsIpad: ['portrait', 'landscape-left'], }, + mockCommandExecutor, + mockFileSystemExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('Project scaffolded successfully'); + expect(result.nextStepParams).toEqual({ + build_sim: { + workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', + scheme: 'TestIOSApp', + simulatorName: 'iPhone 17', + }, + build_run_sim: { + workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', + scheme: 'TestIOSApp', + simulatorName: 'iPhone 17', }, }); }); it('should return success response with customizeNames false', async () => { - const result = await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - outputPath: '/tmp/test-projects', - customizeNames: false, - }, - mockCommandExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + scaffold_ios_projectLogic( { - type: 'text', - text: JSON.stringify( - { - success: true, - projectPath: '/tmp/test-projects', - platform: 'iOS', - message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - nextStepParams: { - build_sim: { - workspacePath: '/tmp/test-projects/MyProject.xcworkspace', - scheme: 'MyProject', - simulatorName: 'iPhone 17', - }, - build_run_sim: { - workspacePath: '/tmp/test-projects/MyProject.xcworkspace', - scheme: 'MyProject', - simulatorName: 'iPhone 17', + projectName: 'TestIOSApp', + outputPath: '/tmp/test-projects', + customizeNames: false, }, + mockCommandExecutor, + mockFileSystemExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('Project scaffolded successfully'); + expect(result.nextStepParams).toEqual({ + build_sim: { + workspacePath: '/tmp/test-projects/MyProject.xcworkspace', + scheme: 'MyProject', + simulatorName: 'iPhone 17', + }, + build_run_sim: { + workspacePath: '/tmp/test-projects/MyProject.xcworkspace', + scheme: 'MyProject', + simulatorName: 'iPhone 17', }, }); }); it('should return error response for invalid project name', async () => { - const result = await scaffold_ios_projectLogic( - { - projectName: '123InvalidName', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - mockCommandExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + scaffold_ios_projectLogic( { - type: 'text', - text: JSON.stringify( - { - success: false, - error: - 'Project name must start with a letter and contain only letters, numbers, and underscores', - }, - null, - 2, - ), + projectName: '123InvalidName', + customizeNames: true, + outputPath: '/tmp/test-projects', }, - ], - isError: true, - }); + mockCommandExecutor, + mockFileSystemExecutor, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Project name must start with a letter'); }); it('should return error response for existing project files', async () => { - // Update mock to return true for existing files mockFileSystemExecutor = createMockFileSystemExecutor({ existsSync: () => true, readFile: async () => 'template content with MyProject placeholder', @@ -521,134 +338,48 @@ describe('scaffold_ios_project plugin', () => { ], }); - const result = await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - mockCommandExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + scaffold_ios_projectLogic( { - type: 'text', - text: JSON.stringify( - { - success: false, - error: 'Xcode project files already exist in /tmp/test-projects', - }, - null, - 2, - ), + projectName: 'TestIOSApp', + customizeNames: true, + outputPath: '/tmp/test-projects', }, - ], - isError: true, - }); + mockCommandExecutor, + mockFileSystemExecutor, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Xcode project files already exist in /tmp/test-projects'); }); it('should return error response for template download failure', async () => { await initConfigStoreForTest({ iosTemplatePath: '' }); - // Mock command executor to fail for curl commands const failingMockCommandExecutor = createMockExecutor({ success: false, output: '', error: 'Template download failed', }); - const result = await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - failingMockCommandExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + scaffold_ios_projectLogic( { - type: 'text', - text: JSON.stringify( - { - success: false, - error: - 'Failed to get template for iOS: Failed to download template: Template download failed', - }, - null, - 2, - ), + projectName: 'TestIOSApp', + customizeNames: true, + outputPath: '/tmp/test-projects', }, - ], - isError: true, - }); - - await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); - }); - - it.skip('should return error response for template extraction failure', async () => { - await initConfigStoreForTest({ iosTemplatePath: '' }); - - // Create a mock that returns false for local template paths to force download - const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ - existsSync: (path) => { - // Only return true for extracted template directories, false for local template paths - return ( - path.includes('xcodebuild-mcp-template') || - path.includes('XcodeBuildMCP-iOS-Template') || - path.includes('extracted') - ); - }, - readFile: async () => 'template content with MyProject placeholder', - readdir: async () => [ - { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, - { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, - ], - mkdir: async () => {}, - rm: async () => {}, - cp: async () => {}, - writeFile: async () => {}, - stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), - }); - - // Mock command executor to fail for unzip commands - const failingMockCommandExecutor = createMockExecutor({ - success: false, - output: '', - error: 'Extraction failed', - }); - - const result = await scaffold_ios_projectLogic( - { - projectName: 'TestIOSApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - failingMockCommandExecutor, - downloadMockFileSystemExecutor, + failingMockCommandExecutor, + mockFileSystemExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: - 'Failed to get template for iOS: Failed to extract template: Extraction failed', - }, - null, - 2, - ), - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Failed to get template for iOS'); + expect(text).toContain('Template download failed'); await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts index 1bb6f1cd..1c1e3fe1 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts @@ -1,15 +1,4 @@ -/** - * Test for scaffold_macos_project plugin - Dependency Injection Architecture - * - * Tests the plugin structure and exported components for scaffold_macos_project tool. - * Uses pure dependency injection with createMockFileSystemExecutor. - * NO VITEST MOCKING ALLOWED - Only createMockExecutor/createMockFileSystemExecutor - * - * Plugin location: plugins/utilities/scaffold_macos_project.js - */ - import { describe, it, expect, beforeEach } from 'vitest'; -import * as z from 'zod'; import { createMockFileSystemExecutor, createNoopExecutor, @@ -23,6 +12,40 @@ import { initConfigStore, type RuntimeConfigOverrides, } from '../../../../utils/config-store.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; const cwd = '/repo'; @@ -31,8 +54,6 @@ async function initConfigStoreForTest(overrides?: RuntimeConfigOverrides): Promi await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); } -// ONLY ALLOWED MOCKING: createMockFileSystemExecutor - describe('scaffold_macos_project plugin', () => { let mockFileSystemExecutor: ReturnType; let templateManagerStub: { @@ -48,7 +69,6 @@ describe('scaffold_macos_project plugin', () => { }; beforeEach(async () => { - // Create template manager stub using pure JavaScript approach let templateManagerCall = ''; let templateManagerError: Error | string | null = null; @@ -68,7 +88,6 @@ describe('scaffold_macos_project plugin', () => { templateManagerCall += `,cleanup(${path})`; return undefined; }, - // Test helpers setError: (error: Error | string | null) => { templateManagerError = error; }, @@ -78,7 +97,6 @@ describe('scaffold_macos_project plugin', () => { }, }; - // Create fresh mock file system executor for each test mockFileSystemExecutor = createMockFileSystemExecutor({ existsSync: () => false, mkdir: async () => {}, @@ -91,7 +109,6 @@ describe('scaffold_macos_project plugin', () => { ], }); - // Replace the real TemplateManager with our stub for most tests (TemplateManager as any).getTemplatePath = templateManagerStub.getTemplatePath; (TemplateManager as any).cleanup = templateManagerStub.cleanup; @@ -104,7 +121,6 @@ describe('scaffold_macos_project plugin', () => { }); it('should have valid schema with required fields', () => { - // Test the schema object exists expect(schema).toBeDefined(); expect(schema.projectName).toBeDefined(); expect(schema.outputPath).toBeDefined(); @@ -116,57 +132,39 @@ describe('scaffold_macos_project plugin', () => { describe('Command Generation', () => { it('should generate correct curl command for macOS template download', async () => { - // This test validates that the curl command would be generated correctly - // by verifying the URL construction logic const expectedUrl = 'https://github.com/getsentry/XcodeBuildMCP-macOS-Template/releases/download/'; - // The curl command should be structured correctly for macOS template expect(expectedUrl).toContain('XcodeBuildMCP-macOS-Template'); expect(expectedUrl).toContain('releases/download'); - // The template zip file should follow the expected pattern const expectedFilename = 'template.zip'; expect(expectedFilename).toMatch(/template\.zip$/); - // The curl command flags should be correct const expectedCurlFlags = ['-L', '-f', '-o']; - expect(expectedCurlFlags).toContain('-L'); // Follow redirects - expect(expectedCurlFlags).toContain('-f'); // Fail on HTTP errors - expect(expectedCurlFlags).toContain('-o'); // Output to file + expect(expectedCurlFlags).toContain('-L'); + expect(expectedCurlFlags).toContain('-f'); + expect(expectedCurlFlags).toContain('-o'); }); it('should generate correct unzip command for template extraction', async () => { - // This test validates that the unzip command would be generated correctly - // by verifying the command structure const expectedUnzipCommand = ['unzip', '-q', 'template.zip']; - // The unzip command should use the quiet flag expect(expectedUnzipCommand).toContain('-q'); - - // The unzip command should target the template zip file expect(expectedUnzipCommand).toContain('template.zip'); - - // The unzip command should be structured correctly expect(expectedUnzipCommand[0]).toBe('unzip'); expect(expectedUnzipCommand[1]).toBe('-q'); expect(expectedUnzipCommand[2]).toMatch(/template\.zip$/); }); it('should generate correct commands for template with version', async () => { - // This test validates that the curl command would be generated correctly with version const testVersion = 'v1.0.0'; const expectedUrlWithVersion = `https://github.com/getsentry/XcodeBuildMCP-macOS-Template/releases/download/${testVersion}/`; - // The URL should contain the specific version expect(expectedUrlWithVersion).toContain(testVersion); expect(expectedUrlWithVersion).toContain('XcodeBuildMCP-macOS-Template'); expect(expectedUrlWithVersion).toContain('releases/download'); - - // The version should be in the correct format expect(testVersion).toMatch(/^v\d+\.\d+\.\d+$/); - - // The full URL should be correctly constructed expect(expectedUrlWithVersion).toBe( `https://github.com/getsentry/XcodeBuildMCP-macOS-Template/releases/download/${testVersion}/`, ); @@ -182,31 +180,30 @@ describe('scaffold_macos_project plugin', () => { }); }; - // Mock local template path exists mockFileSystemExecutor.existsSync = (path: string) => { return path === '/local/template/path' || path === '/local/template/path/template'; }; await initConfigStoreForTest({ macosTemplatePath: '/local/template/path' }); - // Restore original TemplateManager for command generation tests const { TemplateManager: OriginalTemplateManager } = await import( '../../../../utils/template/index.ts' ); (TemplateManager as any).getTemplatePath = OriginalTemplateManager.getTemplatePath; (TemplateManager as any).cleanup = OriginalTemplateManager.cleanup; - await scaffold_macos_projectLogic( - { - projectName: 'TestMacApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - trackingExecutor, - mockFileSystemExecutor, + await runLogic(() => + scaffold_macos_projectLogic( + { + projectName: 'TestMacApp', + customizeNames: true, + outputPath: '/tmp/test-projects', + }, + trackingExecutor, + mockFileSystemExecutor, + ), ); - // Should not generate any curl or unzip commands when using local template expect(capturedCommands).not.toContainEqual( expect.arrayContaining(['curl', expect.anything(), expect.anything()]), ); @@ -214,7 +211,6 @@ describe('scaffold_macos_project plugin', () => { expect.arrayContaining(['unzip', expect.anything(), expect.anything()]), ); - // Restore stub after test (TemplateManager as any).getTemplatePath = templateManagerStub.getTemplatePath; (TemplateManager as any).cleanup = templateManagerStub.cleanup; }); @@ -222,205 +218,145 @@ describe('scaffold_macos_project plugin', () => { describe('Handler Behavior (Complete Literal Returns)', () => { it('should return success response for valid scaffold macOS project request', async () => { - const result = await scaffold_macos_projectLogic( - { - projectName: 'TestMacApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - bundleIdentifier: 'com.test.macapp', - }, - createNoopExecutor(), - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + scaffold_macos_projectLogic( { - type: 'text', - text: JSON.stringify( - { - success: true, - projectPath: '/tmp/test-projects', - platform: 'macOS', - message: 'Successfully scaffolded macOS project "TestMacApp" in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - nextStepParams: { - build_macos: { - workspacePath: '/tmp/test-projects/TestMacApp.xcworkspace', - scheme: 'TestMacApp', - }, - build_run_macos: { - workspacePath: '/tmp/test-projects/TestMacApp.xcworkspace', - scheme: 'TestMacApp', + projectName: 'TestMacApp', + customizeNames: true, + outputPath: '/tmp/test-projects', + bundleIdentifier: 'com.test.macapp', }, + createNoopExecutor(), + mockFileSystemExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('Scaffold macOS Project'); + expect(text).toContain('TestMacApp'); + expect(text).toContain('/tmp/test-projects'); + expect(text).toContain('Project scaffolded successfully'); + expect(result.nextStepParams).toEqual({ + build_macos: { + workspacePath: '/tmp/test-projects/TestMacApp.xcworkspace', + scheme: 'TestMacApp', + }, + build_run_macos: { + workspacePath: '/tmp/test-projects/TestMacApp.xcworkspace', + scheme: 'TestMacApp', }, }); - // Verify template manager calls using manual tracking expect(templateManagerStub.getCalls()).toBe( 'getTemplatePath(macOS),cleanup(/tmp/test-templates/macos)', ); }); it('should return success response with customizeNames false', async () => { - const result = await scaffold_macos_projectLogic( - { - projectName: 'TestMacApp', - outputPath: '/tmp/test-projects', - customizeNames: false, - }, - createNoopExecutor(), - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + scaffold_macos_projectLogic( { - type: 'text', - text: JSON.stringify( - { - success: true, - projectPath: '/tmp/test-projects', - platform: 'macOS', - message: 'Successfully scaffolded macOS project "TestMacApp" in /tmp/test-projects', - }, - null, - 2, - ), - }, - ], - nextStepParams: { - build_macos: { - workspacePath: '/tmp/test-projects/MyProject.xcworkspace', - scheme: 'MyProject', - }, - build_run_macos: { - workspacePath: '/tmp/test-projects/MyProject.xcworkspace', - scheme: 'MyProject', + projectName: 'TestMacApp', + outputPath: '/tmp/test-projects', + customizeNames: false, }, + createNoopExecutor(), + mockFileSystemExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('Project scaffolded successfully'); + expect(result.nextStepParams).toEqual({ + build_macos: { + workspacePath: '/tmp/test-projects/MyProject.xcworkspace', + scheme: 'MyProject', + }, + build_run_macos: { + workspacePath: '/tmp/test-projects/MyProject.xcworkspace', + scheme: 'MyProject', }, }); }); it('should return error response for invalid project name', async () => { - const result = await scaffold_macos_projectLogic( - { - projectName: '123InvalidName', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - createNoopExecutor(), - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + scaffold_macos_projectLogic( { - type: 'text', - text: JSON.stringify( - { - success: false, - error: - 'Project name must start with a letter and contain only letters, numbers, and underscores', - }, - null, - 2, - ), + projectName: '123InvalidName', + customizeNames: true, + outputPath: '/tmp/test-projects', }, - ], - isError: true, - }); + createNoopExecutor(), + mockFileSystemExecutor, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Project name must start with a letter'); }); it('should return error response for existing project files', async () => { - // Override existsSync to return true for workspace file mockFileSystemExecutor.existsSync = () => true; - const result = await scaffold_macos_projectLogic( - { - projectName: 'TestMacApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - createNoopExecutor(), - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + scaffold_macos_projectLogic( { - type: 'text', - text: JSON.stringify( - { - success: false, - error: 'Xcode project files already exist in /tmp/test-projects', - }, - null, - 2, - ), + projectName: 'TestMacApp', + customizeNames: true, + outputPath: '/tmp/test-projects', }, - ], - isError: true, - }); + createNoopExecutor(), + mockFileSystemExecutor, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Xcode project files already exist in /tmp/test-projects'); }); it('should return error response for template manager failure', async () => { templateManagerStub.setError(new Error('Template not found')); - const result = await scaffold_macos_projectLogic( - { - projectName: 'TestMacApp', - customizeNames: true, - outputPath: '/tmp/test-projects', - }, - createNoopExecutor(), - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + scaffold_macos_projectLogic( { - type: 'text', - text: JSON.stringify( - { - success: false, - error: 'Failed to get template for macOS: Template not found', - }, - null, - 2, - ), + projectName: 'TestMacApp', + customizeNames: true, + outputPath: '/tmp/test-projects', }, - ], - isError: true, - }); + createNoopExecutor(), + mockFileSystemExecutor, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Failed to get template for macOS: Template not found'); }); }); describe('File System Operations', () => { it('should create directories and process files correctly', async () => { - await scaffold_macos_projectLogic( - { - projectName: 'TestApp', - customizeNames: true, - outputPath: '/tmp/test', - }, - createNoopExecutor(), - mockFileSystemExecutor, + await runLogic(() => + scaffold_macos_projectLogic( + { + projectName: 'TestApp', + customizeNames: true, + outputPath: '/tmp/test', + }, + createNoopExecutor(), + mockFileSystemExecutor, + ), ); - // Verify template manager calls using manual tracking expect(templateManagerStub.getCalls()).toBe( 'getTemplatePath(macOS),cleanup(/tmp/test-templates/macos)', ); - - // File system operations are called by the mock implementation - // but we can't verify them without vitest mocking patterns - // This test validates the integration works correctly }); }); }); diff --git a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts index 396b4925..3e8b3edf 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts @@ -1,22 +1,20 @@ -/** - * Utilities Plugin: Scaffold iOS Project - * - * Scaffold a new iOS project from templates. - */ - import * as z from 'zod'; -import { join, dirname, basename } from 'path'; +import { join, dirname, basename } from 'node:path'; import { log } from '../../../utils/logging/index.ts'; -import { ValidationError } from '../../../utils/responses/index.ts'; +import { ValidationError } from '../../../utils/errors.ts'; import { TemplateManager } from '../../../utils/template/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, } from '../../../utils/execution/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; -// Common base schema for both iOS and macOS const BaseScaffoldSchema = z.object({ projectName: z.string().min(1), outputPath: z.string(), @@ -27,7 +25,6 @@ const BaseScaffoldSchema = z.object({ customizeNames: z.boolean().default(true), }); -// iOS-specific schema const ScaffoldiOSProjectSchema = BaseScaffoldSchema.extend({ deploymentTarget: z.string().optional(), targetedDeviceFamily: z.array(z.enum(['iphone', 'ipad', 'universal'])).optional(), @@ -343,7 +340,6 @@ async function processDirectory( } } -// Use z.infer for type safety type ScaffoldIOSProjectParams = z.infer; /** @@ -353,29 +349,28 @@ export async function scaffold_ios_projectLogic( params: ScaffoldIOSProjectParams, commandExecutor: CommandExecutor, fileSystemExecutor: FileSystemExecutor, -): Promise { - try { - const projectParams = { ...params, platform: 'iOS' }; - const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor); - - const generatedProjectName = params.customizeNames === false ? 'MyProject' : params.projectName; - const workspacePath = `${projectPath}/${generatedProjectName}.xcworkspace`; - - const response = { - success: true, - projectPath, - platform: 'iOS', - message: `Successfully scaffolded iOS project "${params.projectName}" in ${projectPath}`, - }; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(response, null, 2), - }, - ], - nextStepParams: { +): Promise { + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + const projectParams = { ...params, platform: 'iOS' }; + const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor); + + const generatedProjectName = + params.customizeNames === false ? 'MyProject' : params.projectName; + const workspacePath = `${projectPath}/${generatedProjectName}.xcworkspace`; + + ctx.emit( + header('Scaffold iOS Project', [ + { label: 'Name', value: params.projectName }, + { label: 'Path', value: projectPath }, + { label: 'Platform', value: 'iOS' }, + ]), + ); + ctx.emit(statusLine('success', `Project scaffolded successfully\n └ ${projectPath}`)); + ctx.nextStepParams = { build_sim: { workspacePath, scheme: generatedProjectName, @@ -386,31 +381,18 @@ export async function scaffold_ios_projectLogic( scheme: generatedProjectName, simulatorName: 'iPhone 17', }, - }, - }; - } catch (error) { - log( - 'error', - `Failed to scaffold iOS project: ${error instanceof Error ? error.message : String(error)}`, - ); - - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }, - null, - 2, - ), - }, - ], - isError: true, - }; - } + }; + }, + { + header: header('Scaffold iOS Project', [ + { label: 'Name', value: params.projectName }, + { label: 'Path', value: params.outputPath }, + { label: 'Platform', value: 'iOS' }, + ]), + errorMessage: ({ message }) => message, + logMessage: ({ message }) => `Failed to scaffold iOS project: ${message}`, + }, + ); } /** @@ -480,11 +462,17 @@ async function scaffoldProject( export const schema = ScaffoldiOSProjectSchema.shape; -export async function handler(args: Record): Promise { - const params = ScaffoldiOSProjectSchema.parse(args); - return scaffold_ios_projectLogic( - params, - getDefaultCommandExecutor(), - getDefaultFileSystemExecutor(), - ); +interface ScaffoldIOSToolContext { + commandExecutor: CommandExecutor; + fileSystemExecutor: FileSystemExecutor; } + +export const handler = createTypedToolWithContext( + ScaffoldiOSProjectSchema, + (params: ScaffoldIOSProjectParams, ctx: ScaffoldIOSToolContext) => + scaffold_ios_projectLogic(params, ctx.commandExecutor, ctx.fileSystemExecutor), + () => ({ + commandExecutor: getDefaultCommandExecutor(), + fileSystemExecutor: getDefaultFileSystemExecutor(), + }), +); diff --git a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts index f32f4cc9..118b5b12 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts @@ -1,20 +1,18 @@ -/** - * Utilities Plugin: Scaffold macOS Project - * - * Scaffold a new macOS project from templates. - */ - import * as z from 'zod'; -import { join, dirname, basename } from 'path'; +import { join, dirname, basename } from 'node:path'; import { log } from '../../../utils/logging/index.ts'; -import { ValidationError } from '../../../utils/responses/index.ts'; +import { ValidationError } from '../../../utils/errors.ts'; import { TemplateManager } from '../../../utils/template/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/command.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/command.ts'; import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; -// Common base schema for both iOS and macOS const BaseScaffoldSchema = z.object({ projectName: z.string().min(1), outputPath: z.string(), @@ -25,12 +23,10 @@ const BaseScaffoldSchema = z.object({ customizeNames: z.boolean().default(true), }); -// macOS-specific schema const ScaffoldmacOSProjectSchema = BaseScaffoldSchema.extend({ deploymentTarget: z.string().optional(), }); -// Use z.infer for type safety type ScaffoldMacOSProjectParams = z.infer; /** @@ -327,29 +323,28 @@ export async function scaffold_macos_projectLogic( params: ScaffoldMacOSProjectParams, commandExecutor: CommandExecutor, fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), -): Promise { - try { - const projectParams = { ...params, platform: 'macOS' as const }; - const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor); - - const generatedProjectName = params.customizeNames === false ? 'MyProject' : params.projectName; - const workspacePath = `${projectPath}/${generatedProjectName}.xcworkspace`; - - const response = { - success: true, - projectPath, - platform: 'macOS', - message: `Successfully scaffolded macOS project "${params.projectName}" in ${projectPath}`, - }; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(response, null, 2), - }, - ], - nextStepParams: { +): Promise { + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + const projectParams = { ...params, platform: 'macOS' as const }; + const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor); + + const generatedProjectName = + params.customizeNames === false ? 'MyProject' : params.projectName; + const workspacePath = `${projectPath}/${generatedProjectName}.xcworkspace`; + + ctx.emit( + header('Scaffold macOS Project', [ + { label: 'Name', value: params.projectName }, + { label: 'Path', value: projectPath }, + { label: 'Platform', value: 'macOS' }, + ]), + ); + ctx.emit(statusLine('success', `Project scaffolded successfully\n └ ${projectPath}`)); + ctx.nextStepParams = { build_macos: { workspacePath, scheme: generatedProjectName, @@ -358,40 +353,33 @@ export async function scaffold_macos_projectLogic( workspacePath, scheme: generatedProjectName, }, - }, - }; - } catch (error) { - log( - 'error', - `Failed to scaffold macOS project: ${error instanceof Error ? error.message : String(error)}`, - ); - - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }, - null, - 2, - ), - }, - ], - isError: true, - }; - } + }; + }, + { + header: header('Scaffold macOS Project', [ + { label: 'Name', value: params.projectName }, + { label: 'Path', value: params.outputPath }, + { label: 'Platform', value: 'macOS' }, + ]), + errorMessage: ({ message }) => message, + logMessage: ({ message }) => `Failed to scaffold macOS project: ${message}`, + }, + ); } export const schema = ScaffoldmacOSProjectSchema.shape; -export async function handler(args: Record): Promise { - const validatedArgs = ScaffoldmacOSProjectSchema.parse(args); - return scaffold_macos_projectLogic( - validatedArgs, - getDefaultCommandExecutor(), - getDefaultFileSystemExecutor(), - ); +interface ScaffoldMacOSToolContext { + commandExecutor: CommandExecutor; + fileSystemExecutor: FileSystemExecutor; } + +export const handler = createTypedToolWithContext( + ScaffoldmacOSProjectSchema, + (params: ScaffoldMacOSProjectParams, ctx: ScaffoldMacOSToolContext) => + scaffold_macos_projectLogic(params, ctx.commandExecutor, ctx.fileSystemExecutor), + () => ({ + commandExecutor: getDefaultCommandExecutor(), + fileSystemExecutor: getDefaultFileSystemExecutor(), + }), +); diff --git a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts index d9b9ebca..17fba905 100644 --- a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, sessionClearDefaultsLogic } from '../session_clear_defaults.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('session-clear-defaults tool', () => { beforeEach(() => { @@ -33,11 +35,12 @@ describe('session-clear-defaults tool', () => { describe('Handler Behavior', () => { it('should clear specific keys when provided', async () => { - const result = await sessionClearDefaultsLogic({ - keys: ['scheme', 'deviceId', 'derivedDataPath'], - }); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Session defaults cleared'); + const result = await runLogic(() => + sessionClearDefaultsLogic({ + keys: ['scheme', 'deviceId', 'derivedDataPath'], + }), + ); + expect(result.isError).toBeFalsy(); const current = sessionStore.getAll(); expect(current.scheme).toBeUndefined(); @@ -52,10 +55,9 @@ describe('session-clear-defaults tool', () => { it('should clear env when keys includes env', async () => { sessionStore.setDefaults({ env: { API_URL: 'https://staging.example.com', DEBUG: 'true' } }); - const result = await sessionClearDefaultsLogic({ keys: ['env'] }); + const result = await runLogic(() => sessionClearDefaultsLogic({ keys: ['env'] })); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Session defaults cleared'); + expect(result.isError).toBeFalsy(); const current = sessionStore.getAll(); expect(current.env).toBeUndefined(); @@ -66,9 +68,8 @@ describe('session-clear-defaults tool', () => { sessionStore.setActiveProfile('ios'); sessionStore.setDefaults({ scheme: 'IOS' }); sessionStore.setActiveProfile(null); - const result = await sessionClearDefaultsLogic({ all: true }); - expect(result.isError).toBe(false); - expect(result.content[0].text).toBe('All session defaults cleared'); + const result = await runLogic(() => sessionClearDefaultsLogic({ all: true })); + expect(result.isError).toBeFalsy(); const current = sessionStore.getAll(); expect(Object.keys(current).length).toBe(0); @@ -83,8 +84,8 @@ describe('session-clear-defaults tool', () => { sessionStore.setDefaults({ scheme: 'Global' }); sessionStore.setActiveProfile('ios'); - const result = await sessionClearDefaultsLogic({}); - expect(result.isError).toBe(false); + const result = await runLogic(() => sessionClearDefaultsLogic({})); + expect(result.isError).toBeFalsy(); expect(sessionStore.getAll().scheme).toBe('Global'); expect(sessionStore.listProfiles()).toEqual([]); @@ -100,31 +101,29 @@ describe('session-clear-defaults tool', () => { sessionStore.setDefaults({ scheme: 'Watch' }); sessionStore.setActiveProfile('watch'); - const result = await sessionClearDefaultsLogic({ profile: 'ios' }); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('profile "ios"'); + const result = await runLogic(() => sessionClearDefaultsLogic({ profile: 'ios' })); + expect(result.isError).toBeFalsy(); expect(sessionStore.listProfiles()).toEqual(['watch']); expect(sessionStore.getAll().scheme).toBe('Watch'); }); it('should error when the specified profile does not exist', async () => { - const result = await sessionClearDefaultsLogic({ profile: 'missing' }); + const result = await runLogic(() => sessionClearDefaultsLogic({ profile: 'missing' })); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('does not exist'); + expect(allText(result)).toContain('does not exist'); }); it('should reject all=true when combined with scoped arguments', async () => { - const result = await sessionClearDefaultsLogic({ all: true, profile: 'ios' }); + const result = await runLogic(() => sessionClearDefaultsLogic({ all: true, profile: 'ios' })); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('cannot be combined'); + expect(allText(result)).toContain('cannot be combined'); }); it('should validate keys enum', async () => { const result = (await handler({ keys: ['invalid' as any] })) as any; expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('keys'); + expect(allText(result)).toContain('keys'); }); }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index 774f9861..92a42c20 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -6,6 +6,8 @@ import { sessionStore } from '../../../../utils/session-store.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, sessionSetDefaultsLogic } from '../session_set_defaults.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('session-set-defaults tool', () => { beforeEach(() => { @@ -55,18 +57,19 @@ describe('session-set-defaults tool', () => { describe('Handler Behavior', () => { it('should set provided defaults and return updated state', async () => { - const result = await sessionSetDefaultsLogic( - { - scheme: 'MyScheme', - simulatorName: 'iPhone 17', - useLatestOS: true, - arch: 'arm64', - }, - createContext(), + const result = await runLogic(() => + sessionSetDefaultsLogic( + { + scheme: 'MyScheme', + simulatorName: 'iPhone 17', + useLatestOS: true, + arch: 'arm64', + }, + createContext(), + ), ); expect(result.isError).toBeFalsy(); - expect(result.content[0].text).toContain('Defaults updated:'); const current = sessionStore.getAll(); expect(current.scheme).toBe('MyScheme'); @@ -83,8 +86,7 @@ describe('session-set-defaults tool', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('useLatestOS'); + expect(allText(result)).toContain('useLatestOS'); }); it('should reject env values that are not strings', async () => { @@ -95,8 +97,7 @@ describe('session-set-defaults tool', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('env'); + expect(allText(result)).toContain('env'); }); it('should reject empty string defaults for required string fields', async () => { @@ -105,72 +106,61 @@ describe('session-set-defaults tool', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('scheme'); + expect(allText(result)).toContain('scheme'); }); it('should clear workspacePath when projectPath is set', async () => { sessionStore.setDefaults({ workspacePath: '/old/App.xcworkspace' }); - const result = await sessionSetDefaultsLogic( - { projectPath: '/new/App.xcodeproj' }, - createContext(), + const result = await runLogic(() => + sessionSetDefaultsLogic({ projectPath: '/new/App.xcodeproj' }, createContext()), ); const current = sessionStore.getAll(); expect(current.projectPath).toBe('/new/App.xcodeproj'); expect(current.workspacePath).toBeUndefined(); - expect(result.content[0].text).toContain( - 'Cleared workspacePath because projectPath was set.', - ); + expect(result.isError).toBeFalsy(); }); it('should clear projectPath when workspacePath is set', async () => { sessionStore.setDefaults({ projectPath: '/old/App.xcodeproj' }); - const result = await sessionSetDefaultsLogic( - { workspacePath: '/new/App.xcworkspace' }, - createContext(), + const result = await runLogic(() => + sessionSetDefaultsLogic({ workspacePath: '/new/App.xcworkspace' }, createContext()), ); const current = sessionStore.getAll(); expect(current.workspacePath).toBe('/new/App.xcworkspace'); expect(current.projectPath).toBeUndefined(); - expect(result.content[0].text).toContain( - 'Cleared projectPath because workspacePath was set.', - ); + expect(result.isError).toBeFalsy(); }); it('should clear stale simulatorName when simulatorId is explicitly set', async () => { sessionStore.setDefaults({ simulatorName: 'Old Name' }); - const result = await sessionSetDefaultsLogic( - { simulatorId: 'RESOLVED-SIM-UUID' }, - createContext(), + const result = await runLogic(() => + sessionSetDefaultsLogic({ simulatorId: 'RESOLVED-SIM-UUID' }, createContext()), ); const current = sessionStore.getAll(); expect(current.simulatorId).toBe('RESOLVED-SIM-UUID'); expect(current.simulatorName).toBeUndefined(); - expect(result.content[0].text).toContain( - 'Cleared simulatorName because simulatorId was set; background resolution will repopulate it.', - ); + expect(result.isError).toBeFalsy(); }); it('should clear stale simulatorId when only simulatorName is set', async () => { sessionStore.setDefaults({ simulatorId: 'OLD-SIM-UUID' }); - const result = await sessionSetDefaultsLogic({ simulatorName: 'iPhone 17' }, createContext()); + const result = await runLogic(() => + sessionSetDefaultsLogic({ simulatorName: 'iPhone 17' }, createContext()), + ); const current = sessionStore.getAll(); // simulatorId resolution happens in background; stale id is cleared immediately expect(current.simulatorName).toBe('iPhone 17'); expect(current.simulatorId).toBeUndefined(); - expect(result.content[0].text).toContain( - 'Cleared simulatorId because simulatorName was set; background resolution will repopulate it.', - ); + expect(result.isError).toBeFalsy(); }); it('does not claim simulatorName was cleared when none existed', async () => { sessionStore.setDefaults({ simulatorId: 'RESOLVED-SIM-UUID' }); - const result = await sessionSetDefaultsLogic( - { simulatorId: 'RESOLVED-SIM-UUID' }, - createContext(), + const result = await runLogic(() => + sessionSetDefaultsLogic({ simulatorId: 'RESOLVED-SIM-UUID' }, createContext()), ); - expect(result.content[0].text).not.toContain('Cleared simulatorName'); + expect(result.isError).toBeFalsy(); }); it('should not fail when simulatorName cannot be resolved immediately', async () => { @@ -192,45 +182,47 @@ describe('session-set-defaults tool', () => { }), }; - const result = await sessionSetDefaultsLogic( - { simulatorName: 'NonExistentSimulator' }, - contextWithFailingExecutor, + const result = await runLogic(() => + sessionSetDefaultsLogic( + { simulatorName: 'NonExistentSimulator' }, + contextWithFailingExecutor, + ), ); - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); expect(sessionStore.getAll().simulatorName).toBe('NonExistentSimulator'); }); it('should prefer workspacePath when both projectPath and workspacePath are provided', async () => { - const res = await sessionSetDefaultsLogic( - { - projectPath: '/app/App.xcodeproj', - workspacePath: '/app/App.xcworkspace', - }, - createContext(), + const res = await runLogic(() => + sessionSetDefaultsLogic( + { + projectPath: '/app/App.xcodeproj', + workspacePath: '/app/App.xcworkspace', + }, + createContext(), + ), ); const current = sessionStore.getAll(); expect(current.workspacePath).toBe('/app/App.xcworkspace'); expect(current.projectPath).toBeUndefined(); - expect(res.content[0].text).toContain( - 'Both projectPath and workspacePath were provided; keeping workspacePath and ignoring projectPath.', - ); + expect(res.isError).toBeFalsy(); }); it('should keep both simulatorId and simulatorName when both are provided', async () => { - const res = await sessionSetDefaultsLogic( - { - simulatorId: 'SIM-1', - simulatorName: 'iPhone 17', - }, - createContext(), + const res = await runLogic(() => + sessionSetDefaultsLogic( + { + simulatorId: 'SIM-1', + simulatorName: 'iPhone 17', + }, + createContext(), + ), ); const current = sessionStore.getAll(); // Both are kept, simulatorId takes precedence for tools expect(current.simulatorId).toBe('SIM-1'); expect(current.simulatorName).toBe('iPhone 17'); - expect(res.content[0].text).toContain( - 'Both simulatorId and simulatorName were provided; simulatorId will be used by tools.', - ); + expect(res.isError).toBeFalsy(); }); it('should persist defaults when persist is true', async () => { @@ -258,16 +250,17 @@ describe('session-set-defaults tool', () => { await initConfigStore({ cwd, fs }); - const result = await sessionSetDefaultsLogic( - { - workspacePath: '/new/App.xcworkspace', - simulatorId: 'RESOLVED-SIM-UUID', - persist: true, - }, - createContext(), + const result = await runLogic(() => + sessionSetDefaultsLogic( + { + workspacePath: '/new/App.xcworkspace', + simulatorId: 'RESOLVED-SIM-UUID', + persist: true, + }, + createContext(), + ), ); - expect(result.content[0].text).toContain('Persisted defaults to'); expect(writes.length).toBe(1); expect(writes[0].path).toBe(configPath); @@ -285,48 +278,51 @@ describe('session-set-defaults tool', () => { sessionStore.setDefaults({ scheme: 'OldIOS' }); sessionStore.setActiveProfile(null); - const result = await sessionSetDefaultsLogic( - { - profile: 'ios', - scheme: 'NewIOS', - simulatorName: 'iPhone 17', - }, - createContext(), + const result = await runLogic(() => + sessionSetDefaultsLogic( + { + profile: 'ios', + scheme: 'NewIOS', + simulatorName: 'iPhone 17', + }, + createContext(), + ), ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Activated profile "ios".'); + expect(result.isError).toBeFalsy(); expect(sessionStore.getActiveProfile()).toBe('ios'); expect(sessionStore.getAll().scheme).toBe('NewIOS'); expect(sessionStore.getAll().simulatorName).toBe('iPhone 17'); }); it('returns error when profile does not exist and createIfNotExists is false', async () => { - const result = await sessionSetDefaultsLogic( - { - profile: 'missing', - scheme: 'NewIOS', - }, - createContext(), + const result = await runLogic(() => + sessionSetDefaultsLogic( + { + profile: 'missing', + scheme: 'NewIOS', + }, + createContext(), + ), ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Profile "missing" does not exist'); - expect(result.content[0].text).toContain('createIfNotExists=true'); + expect(allText(result)).toContain('Profile "missing" does not exist'); }); it('creates profile when createIfNotExists is true and activates it', async () => { - const result = await sessionSetDefaultsLogic( - { - profile: 'ios', - createIfNotExists: true, - scheme: 'NewIOS', - }, - createContext(), + const result = await runLogic(() => + sessionSetDefaultsLogic( + { + profile: 'ios', + createIfNotExists: true, + scheme: 'NewIOS', + }, + createContext(), + ), ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Created and activated profile "ios".'); + expect(result.isError).toBeFalsy(); expect(sessionStore.getActiveProfile()).toBe('ios'); expect(sessionStore.getAll().scheme).toBe('NewIOS'); }); @@ -360,14 +356,16 @@ describe('session-set-defaults tool', () => { sessionStore.setActiveProfile('ios'); sessionStore.setActiveProfile(null); - await sessionSetDefaultsLogic( - { - profile: 'ios', - scheme: 'NewIOS', - simulatorName: 'iPhone 17', - persist: true, - }, - createContext(), + await runLogic(() => + sessionSetDefaultsLogic( + { + profile: 'ios', + scheme: 'NewIOS', + simulatorName: 'iPhone 17', + persist: true, + }, + createContext(), + ), ); expect(writes.length).toBe(2); @@ -382,9 +380,11 @@ describe('session-set-defaults tool', () => { it('should store env as a Record default', async () => { const envVars = { STAGING_ENABLED: '1', DEBUG: 'true' }; - const result = await sessionSetDefaultsLogic({ env: envVars }, createContext()); + const result = await runLogic(() => + sessionSetDefaultsLogic({ env: envVars }, createContext()), + ); - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); expect(sessionStore.getAll().env).toEqual(envVars); }); @@ -408,12 +408,10 @@ describe('session-set-defaults tool', () => { await initConfigStore({ cwd, fs }); const envVars = { API_URL: 'https://staging.example.com' }; - const result = await sessionSetDefaultsLogic( - { env: envVars, persist: true }, - createContext(), + const result = await runLogic(() => + sessionSetDefaultsLogic({ env: envVars, persist: true }, createContext()), ); - expect(result.content[0].text).toContain('Persisted defaults to'); expect(writes.length).toBe(1); const parsed = parseYaml(writes[0].content) as { @@ -423,9 +421,23 @@ describe('session-set-defaults tool', () => { }); it('should not persist when persist is true but no defaults were provided', async () => { - const result = await sessionSetDefaultsLogic({ persist: true }, createContext()); + const writes: { path: string; content: string }[] = []; + const fs = createMockFileSystemExecutor({ + existsSync: () => false, + writeFile: async (targetPath: string, content: string) => { + writes.push({ path: targetPath, content }); + }, + }); - expect(result.content[0].text).toContain('No defaults provided to persist'); + await initConfigStore({ cwd, fs }); + + const result = await runLogic(() => + sessionSetDefaultsLogic({ persist: true }, createContext()), + ); + + expect(result.isError).toBeFalsy(); + expect(writes).toEqual([]); + expect(sessionStore.getAll()).toEqual({}); }); }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts index b61488cb..b3519db5 100644 --- a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler } from '../session_show_defaults.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('session-show-defaults tool', () => { beforeEach(() => { @@ -22,34 +23,14 @@ describe('session-show-defaults tool', () => { }); describe('Handler Behavior', () => { - it('should return empty defaults when none set', async () => { - const result = await handler(); - expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); - expect(typeof result.content[0].text).toBe('string'); - const parsed = JSON.parse(result.content[0].text as string); - expect(parsed).toEqual({}); - }); - - it('should return current defaults when set', async () => { - sessionStore.setDefaults({ scheme: 'MyScheme', simulatorId: 'SIM-123' }); - const result = await handler(); - expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); - expect(typeof result.content[0].text).toBe('string'); - const parsed = JSON.parse(result.content[0].text as string); - expect(parsed.scheme).toBe('MyScheme'); - expect(parsed.simulatorId).toBe('SIM-123'); - }); - it('shows defaults from the active profile', async () => { sessionStore.setDefaults({ scheme: 'GlobalScheme' }); sessionStore.setActiveProfile('ios'); sessionStore.setDefaults({ scheme: 'IOSScheme' }); - const result = await handler(); - const parsed = JSON.parse(result.content[0].text as string); - expect(parsed.scheme).toBe('IOSScheme'); + const result = await handler({}); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('scheme: IOSScheme'); }); }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts index 66e6cb83..75ae836d 100644 --- a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts @@ -9,6 +9,8 @@ import { schema, sessionUseDefaultsProfileLogic, } from '../session_use_defaults_profile.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('session-use-defaults-profile tool', () => { beforeEach(() => { @@ -29,41 +31,43 @@ describe('session-use-defaults-profile tool', () => { sessionStore.setActiveProfile('ios'); sessionStore.setActiveProfile(null); - const result = await sessionUseDefaultsProfileLogic({ profile: 'ios' }); - expect(result.isError).toBe(false); + const result = await runLogic(() => sessionUseDefaultsProfileLogic({ profile: 'ios' })); + expect(result.isError).toBeFalsy(); expect(sessionStore.getActiveProfile()).toBe('ios'); expect(sessionStore.listProfiles()).toContain('ios'); }); it('switches back to global profile', async () => { sessionStore.setActiveProfile('watch'); - const result = await sessionUseDefaultsProfileLogic({ global: true }); - expect(result.isError).toBe(false); + const result = await runLogic(() => sessionUseDefaultsProfileLogic({ global: true })); + expect(result.isError).toBeFalsy(); expect(sessionStore.getActiveProfile()).toBeNull(); }); it('returns error when both global and profile are provided', async () => { - const result = await sessionUseDefaultsProfileLogic({ global: true, profile: 'ios' }); + const result = await runLogic(() => + sessionUseDefaultsProfileLogic({ global: true, profile: 'ios' }), + ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('either global=true or profile'); + expect(allText(result)).toContain('either global=true or profile'); }); it('returns error when profile does not exist', async () => { - const result = await sessionUseDefaultsProfileLogic({ profile: 'macos' }); + const result = await runLogic(() => sessionUseDefaultsProfileLogic({ profile: 'macos' })); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('does not exist'); + expect(allText(result)).toContain('does not exist'); }); it('returns error when profile name is blank after trimming', async () => { - const result = await sessionUseDefaultsProfileLogic({ profile: ' ' }); + const result = await runLogic(() => sessionUseDefaultsProfileLogic({ profile: ' ' })); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Profile name cannot be empty'); + expect(allText(result)).toContain('Profile name cannot be empty'); }); it('returns status for empty args', async () => { - const result = await sessionUseDefaultsProfileLogic({}); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Active defaults profile: global'); + const result = await runLogic(() => sessionUseDefaultsProfileLogic({})); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Activated profile (default profile)'); }); it('persists active profile when persist=true', async () => { @@ -80,9 +84,11 @@ describe('session-use-defaults-profile tool', () => { sessionStore.setActiveProfile('ios'); sessionStore.setActiveProfile(null); - const result = await sessionUseDefaultsProfileLogic({ profile: 'ios', persist: true }); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Persisted active profile selection'); + const result = await runLogic(() => + sessionUseDefaultsProfileLogic({ profile: 'ios', persist: true }), + ); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('Persisted active profile selection'); expect(writes).toHaveLength(1); const parsed = parseYaml(writes[0].content) as { activeSessionDefaultsProfile?: string }; expect(parsed.activeSessionDefaultsProfile).toBe('ios'); @@ -100,8 +106,10 @@ describe('session-use-defaults-profile tool', () => { }); await initConfigStore({ cwd, fs }); - const result = await sessionUseDefaultsProfileLogic({ global: true, persist: true }); - expect(result.isError).toBe(false); + const result = await runLogic(() => + sessionUseDefaultsProfileLogic({ global: true, persist: true }), + ); + expect(result.isError).toBeFalsy(); expect(writes).toHaveLength(1); const parsed = parseYaml(writes[0].content) as { activeSessionDefaultsProfile?: string }; expect(parsed.activeSessionDefaultsProfile).toBeUndefined(); diff --git a/src/mcp/tools/session-management/session-format-helpers.ts b/src/mcp/tools/session-management/session-format-helpers.ts new file mode 100644 index 00000000..cb2e1650 --- /dev/null +++ b/src/mcp/tools/session-management/session-format-helpers.ts @@ -0,0 +1,30 @@ +import { sessionDefaultKeys } from '../../../utils/session-defaults-schema.ts'; +import type { SessionDefaults } from '../../../utils/session-store.ts'; + +export function formatProfileLabel(profile: string | null): string { + return profile ?? '(default)'; +} + +export function formatProfileAnnotation(profile: string | null): string { + if (profile === null) { + return '(default profile)'; + } + return `(${profile} profile)`; +} + +export function buildFullDetailTree( + defaults: SessionDefaults, +): Array<{ label: string; value: string }> { + return sessionDefaultKeys.map((key) => { + const raw = defaults[key]; + const value = raw !== undefined ? String(raw) : '(not set)'; + return { label: key, value }; + }); +} + +export function formatDetailLines(items: Array<{ label: string; value: string }>): string[] { + return items.map((item, index) => { + const branch = index === items.length - 1 ? '\u2514' : '\u251C'; + return `${branch} ${item.label}: ${item.value}`; + }); +} diff --git a/src/mcp/tools/session-management/session_clear_defaults.ts b/src/mcp/tools/session-management/session_clear_defaults.ts index 428ab181..1f99c1a6 100644 --- a/src/mcp/tools/session-management/session_clear_defaults.ts +++ b/src/mcp/tools/session-management/session_clear_defaults.ts @@ -1,9 +1,10 @@ import * as z from 'zod'; import { sessionStore } from '../../../utils/session-store.ts'; import { sessionDefaultKeys } from '../../../utils/session-defaults-schema.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { formatProfileLabel, formatProfileAnnotation } from './session-format-helpers.ts'; const keys = sessionDefaultKeys; @@ -24,38 +25,34 @@ const schemaObj = z.object({ type Params = z.infer; -export async function sessionClearDefaultsLogic(params: Params): Promise { +export async function sessionClearDefaultsLogic(params: Params): Promise { + const ctx = getHandlerContext(); + if (params.all) { if (params.profile !== undefined || params.keys !== undefined) { - return { - content: [ - { - type: 'text', - text: 'all=true cannot be combined with profile or keys.', - }, - ], - isError: true, - }; + ctx.emit(header('Clear Defaults')); + ctx.emit(statusLine('error', 'all=true cannot be combined with profile or keys.')); + return; } sessionStore.clearAll(); - return { content: [{ type: 'text', text: 'All session defaults cleared' }], isError: false }; + ctx.emit(header('Clear Defaults')); + ctx.emit(statusLine('success', 'All session defaults cleared.')); + return; } const profile = params.profile?.trim(); if (profile !== undefined) { if (profile.length === 0) { - return { - content: [{ type: 'text', text: 'Profile name cannot be empty.' }], - isError: true, - }; + ctx.emit(header('Clear Defaults')); + ctx.emit(statusLine('error', 'Profile name cannot be empty.')); + return; } if (!sessionStore.listProfiles().includes(profile)) { - return { - content: [{ type: 'text', text: `Profile "${profile}" does not exist.` }], - isError: true, - }; + ctx.emit(header('Clear Defaults')); + ctx.emit(statusLine('error', `Profile "${profile}" does not exist.`)); + return; } if (params.keys) { @@ -64,19 +61,26 @@ export async function sessionClearDefaultsLogic(params: Params): Promise = { + projectPath: 'Project Path', + workspacePath: 'Workspace Path', + scheme: 'Scheme', + configuration: 'Configuration', + simulatorName: 'Simulator Name', + simulatorId: 'Simulator ID', + simulatorPlatform: 'Simulator Platform', + deviceId: 'Device ID', + useLatestOS: 'Use Latest OS', + arch: 'Architecture', + suppressWarnings: 'Suppress Warnings', + derivedDataPath: 'Derived Data Path', + preferXcodebuild: 'Prefer xcodebuild', + platform: 'Platform', + bundleId: 'Bundle ID', + env: 'Environment', +}; + export async function sessionSetDefaultsLogic( params: Params, context: SessionSetDefaultsContext, -): Promise { +): Promise { + const ctx = getHandlerContext(); const notices: string[] = []; let activeProfile = sessionStore.getActiveProfile(); - const { - persist, - profile: rawProfile, - createIfNotExists: rawCreateIfNotExists, - ...rawParams - } = params; - const createIfNotExists = rawCreateIfNotExists ?? false; + const { persist, profile: rawProfile, createIfNotExists = false, ...rawParams } = params; if (rawProfile !== undefined) { const profile = rawProfile.trim(); if (profile.length === 0) { - return { - content: [{ type: 'text', text: 'Profile name cannot be empty.' }], - isError: true, - }; + ctx.emit(header('Set Defaults')); + ctx.emit(statusLine('error', 'Profile name cannot be empty.')); + return; } const profileExists = sessionStore.listProfiles().includes(profile); if (!profileExists && !createIfNotExists) { - return { - content: [ - { - type: 'text', - text: `Profile "${profile}" does not exist. Pass createIfNotExists=true to create it.`, - }, - ], - isError: true, - }; + ctx.emit(header('Set Defaults')); + ctx.emit( + statusLine( + 'error', + `Profile "${profile}" does not exist. Pass createIfNotExists=true to create it.`, + ), + ); + return; } sessionStore.setActiveProfile(profile); @@ -85,18 +108,10 @@ export async function sessionSetDefaultsLogic( rawParams as Record, ) as Partial; - const hasProjectPath = - Object.prototype.hasOwnProperty.call(nextParams, 'projectPath') && - nextParams.projectPath !== undefined; - const hasWorkspacePath = - Object.prototype.hasOwnProperty.call(nextParams, 'workspacePath') && - nextParams.workspacePath !== undefined; - const hasSimulatorId = - Object.prototype.hasOwnProperty.call(nextParams, 'simulatorId') && - nextParams.simulatorId !== undefined; - const hasSimulatorName = - Object.prototype.hasOwnProperty.call(nextParams, 'simulatorName') && - nextParams.simulatorName !== undefined; + const hasProjectPath = nextParams.projectPath !== undefined; + const hasWorkspacePath = nextParams.workspacePath !== undefined; + const hasSimulatorId = nextParams.simulatorId !== undefined; + const hasSimulatorName = nextParams.simulatorName !== undefined; if (hasProjectPath && hasWorkspacePath) { delete nextParams.projectPath; @@ -105,21 +120,14 @@ export async function sessionSetDefaultsLogic( ); } - // Clear mutually exclusive counterparts before merging new defaults const toClear = new Set(); - if ( - Object.prototype.hasOwnProperty.call(nextParams, 'projectPath') && - nextParams.projectPath !== undefined - ) { + if (hasProjectPath) { toClear.add('workspacePath'); if (current.workspacePath !== undefined) { notices.push('Cleared workspacePath because projectPath was set.'); } } - if ( - Object.prototype.hasOwnProperty.call(nextParams, 'workspacePath') && - nextParams.workspacePath !== undefined - ) { + if (hasWorkspacePath) { toClear.add('projectPath'); if (current.projectPath !== undefined) { notices.push('Cleared projectPath because workspacePath was set.'); @@ -132,7 +140,6 @@ export async function sessionSetDefaultsLogic( hasSimulatorName && nextParams.simulatorName !== current.simulatorName; if (hasSimulatorId && hasSimulatorName) { - // Both provided - keep both, simulatorId takes precedence for tools notices.push( 'Both simulatorId and simulatorName were provided; simulatorId will be used by tools.', ); @@ -212,16 +219,24 @@ export async function sessionSetDefaultsLogic( } const updated = sessionStore.getAll(); - const noticeText = notices.length > 0 ? `\nNotices:\n- ${notices.join('\n- ')}` : ''; - return { - content: [ - { - type: 'text', - text: `Defaults updated:\n${JSON.stringify(updated, null, 2)}${noticeText}`, - }, - ], - isError: false, - }; + + const headerParams: Array<{ label: string; value: string }> = []; + for (const [key, value] of Object.entries(rawParams)) { + if (value !== undefined) { + const label = PARAM_LABEL_MAP[key] ?? key; + headerParams.push({ label, value: String(value) }); + } + } + headerParams.push({ label: 'Profile', value: formatProfileLabel(activeProfile) }); + + const profileAnnotation = formatProfileAnnotation(activeProfile); + ctx.emit(header('Set Defaults', headerParams)); + ctx.emit(statusLine('success', `Session defaults updated ${profileAnnotation}`)); + ctx.emit(detailTree(buildFullDetailTree(updated))); + + if (notices.length > 0) { + ctx.emit(section('Notices', notices)); + } } export const schema = schemaObj.shape; diff --git a/src/mcp/tools/session-management/session_show_defaults.ts b/src/mcp/tools/session-management/session_show_defaults.ts index 03ce99da..cb06676c 100644 --- a/src/mcp/tools/session-management/session_show_defaults.ts +++ b/src/mcp/tools/session-management/session_show_defaults.ts @@ -1,9 +1,37 @@ +import * as z from 'zod'; import { sessionStore } from '../../../utils/session-store.ts'; -import type { ToolResponse } from '../../../types/common.ts'; +import { header, section } from '../../../utils/tool-event-builders.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; +import { + formatProfileLabel, + buildFullDetailTree, + formatDetailLines, +} from './session-format-helpers.ts'; -export const schema = {}; +const schemaObject = z.object({}); -export const handler = async (): Promise => { - const current = sessionStore.getAll(); - return { content: [{ type: 'text', text: JSON.stringify(current, null, 2) }], isError: false }; -}; +export async function sessionShowDefaultsLogic(): Promise { + const ctx = getHandlerContext(); + const namedProfiles = sessionStore.listProfiles(); + const profileKeys: Array = [null, ...namedProfiles]; + + ctx.emit(header('Show Defaults')); + + for (const profileKey of profileKeys) { + const defaults = sessionStore.getAllForProfile(profileKey); + const label = `\u{1F4C1} ${formatProfileLabel(profileKey)}`; + const items = buildFullDetailTree(defaults); + ctx.emit(section(label, formatDetailLines(items))); + } +} + +export const schema = schemaObject.shape; + +export const handler = createTypedToolWithContext( + schemaObject, + () => sessionShowDefaultsLogic(), + () => undefined, +); diff --git a/src/mcp/tools/session-management/session_use_defaults_profile.ts b/src/mcp/tools/session-management/session_use_defaults_profile.ts index ab132168..ae4e2922 100644 --- a/src/mcp/tools/session-management/session_use_defaults_profile.ts +++ b/src/mcp/tools/session-management/session_use_defaults_profile.ts @@ -1,9 +1,10 @@ import * as z from 'zod'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { persistActiveSessionDefaultsProfile } from '../../../utils/config-store.ts'; import { sessionStore } from '../../../utils/session-store.ts'; -import type { ToolResponse } from '../../../types/common.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; +import { formatProfileLabel, formatProfileAnnotation } from './session-format-helpers.ts'; const schemaObj = z.object({ profile: z @@ -20,53 +21,37 @@ const schemaObj = z.object({ type Params = z.input; -function normalizeProfileName(profile: string): string { - return profile.trim(); -} - -function errorResponse(text: string): ToolResponse { - return { - content: [{ type: 'text', text }], - isError: true, - }; -} - function resolveProfileToActivate(params: Params): string | null | undefined { if (params.global === true) return null; if (params.profile === undefined) return undefined; - return normalizeProfileName(params.profile); + return params.profile.trim(); } -function validateProfileActivation( - profileToActivate: string | null | undefined, -): ToolResponse | null { - if (profileToActivate === undefined || profileToActivate === null) { - return null; - } - - if (profileToActivate.length === 0) { - return errorResponse('Profile name cannot be empty.'); - } - - const profileExists = sessionStore.listProfiles().includes(profileToActivate); - if (!profileExists) { - return errorResponse(`Profile "${profileToActivate}" does not exist.`); - } - - return null; -} - -export async function sessionUseDefaultsProfileLogic(params: Params): Promise { +export async function sessionUseDefaultsProfileLogic(params: Params): Promise { + const ctx = getHandlerContext(); const notices: string[] = []; + const errorHeader = header('Use Defaults Profile'); if (params.global === true && params.profile !== undefined) { - return errorResponse('Provide either global=true or profile, not both.'); + ctx.emit(errorHeader); + ctx.emit(statusLine('error', 'Provide either global=true or profile, not both.')); + return; } + const beforeProfile = sessionStore.getActiveProfile(); const profileToActivate = resolveProfileToActivate(params); - const validationError = validateProfileActivation(profileToActivate); - if (validationError) { - return validationError; + + if (typeof profileToActivate === 'string') { + if (profileToActivate.length === 0) { + ctx.emit(errorHeader); + ctx.emit(statusLine('error', 'Profile name cannot be empty.')); + return; + } + if (!sessionStore.listProfiles().includes(profileToActivate)) { + ctx.emit(errorHeader); + ctx.emit(statusLine('error', `Profile "${profileToActivate}" does not exist.`)); + return; + } } if (profileToActivate !== undefined) { @@ -79,24 +64,18 @@ export async function sessionUseDefaultsProfileLogic(params: Params): Promise 0) { + ctx.emit(section('Notices', notices)); + } - return { - content: [ - { - type: 'text', - text: [ - `Active defaults profile: ${activeLabel}`, - `Known profiles: ${profiles.length > 0 ? profiles.join(', ') : '(none)'}`, - `Current defaults: ${JSON.stringify(current, null, 2)}`, - ...(notices.length > 0 ? [`Notices:`, ...notices.map((notice) => `- ${notice}`)] : []), - ].join('\n'), - }, - ], - isError: false, - }; + const profileAnnotation = formatProfileAnnotation(active); + ctx.emit(statusLine('success', `Activated profile ${profileAnnotation}`)); } export const schema = schemaObj.shape; diff --git a/src/mcp/tools/swift-package/__tests__/active-processes.test.ts b/src/mcp/tools/swift-package/__tests__/active-processes.test.ts index 86244a11..fcc5b22b 100644 --- a/src/mcp/tools/swift-package/__tests__/active-processes.test.ts +++ b/src/mcp/tools/swift-package/__tests__/active-processes.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for active-processes module - * Following CLAUDE.md testing standards with literal validation - */ - import { describe, it, expect, beforeEach } from 'vitest'; import { activeProcesses, diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts index 7387e8fc..96728775 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for swift_package_build plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -12,9 +6,15 @@ import { createNoopExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; +import { runToolLogic } from '../../../../test-utils/test-helpers.ts'; import { schema, handler, swift_package_buildLogic } from '../swift_package_build.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; +const runSwiftPackageBuildLogic = ( + params: Parameters[0], + executor: Parameters[1], +) => runToolLogic(() => swift_package_buildLogic(params, executor)); + describe('swift_package_build plugin', () => { describe('Export Field Validation (Literal)', () => { it('should have handler function', () => { @@ -70,7 +70,7 @@ describe('swift_package_build plugin', () => { }); }; - await swift_package_buildLogic( + await runSwiftPackageBuildLogic( { packagePath: '/test/package', }, @@ -97,7 +97,7 @@ describe('swift_package_build plugin', () => { }); }; - await swift_package_buildLogic( + await runSwiftPackageBuildLogic( { packagePath: '/test/package', configuration: 'release', @@ -125,7 +125,7 @@ describe('swift_package_build plugin', () => { }); }; - await swift_package_buildLogic( + await runSwiftPackageBuildLogic( { packagePath: '/test/package', targetName: 'MyTarget', @@ -164,18 +164,17 @@ describe('swift_package_build plugin', () => { describe('Response Logic Testing', () => { it('should handle missing packagePath parameter (Zod handles validation)', async () => { - // Note: With createTypedTool, Zod validation happens before the logic function is called - // So we test with a valid but minimal parameter set since validation is handled upstream const executor = createMockExecutor({ success: true, output: 'Build succeeded', }); - const result = await swift_package_buildLogic({ packagePath: '/test/package' }, executor); + const { result } = await runSwiftPackageBuildLogic( + { packagePath: '/test/package' }, + executor, + ); - // The logic function should execute normally with valid parameters - // Zod validation errors are handled by createTypedTool wrapper - expect(result.isError).toBe(false); + expect(result.isError()).toBeFalsy(); }); it('should return successful build response', async () => { @@ -184,24 +183,14 @@ describe('swift_package_build plugin', () => { output: 'Build complete.', }); - const result = await swift_package_buildLogic( + const { result } = await runSwiftPackageBuildLogic( { packagePath: '/test/package', }, executor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '✅ Swift package build succeeded.' }, - { - type: 'text', - text: '💡 Next: Run tests with swift_package_test or execute with swift_package_run', - }, - { type: 'text', text: 'Build complete.' }, - ], - isError: false, - }); + expect(result.isError()).toBeFalsy(); }); it('should return error response for build failure', async () => { @@ -210,22 +199,17 @@ describe('swift_package_build plugin', () => { error: 'Compilation failed: error in main.swift', }); - const result = await swift_package_buildLogic( + const { result } = await runSwiftPackageBuildLogic( { packagePath: '/test/package', }, executor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Swift package build failed\nDetails: Compilation failed: error in main.swift', - }, - ], - isError: true, - }); + expect(result.isError()).toBe(true); + const text = result.text(); + expect(text).toContain('Swift package build failed'); + expect(text).toContain('Compilation failed: error in main.swift'); }); it('should include stdout diagnostics when stderr is empty on build failure', async () => { @@ -236,22 +220,17 @@ describe('swift_package_build plugin', () => { "main.swift:10:25: error: cannot find type 'DOESNOTEXIST' in scope\nlet broken: DOESNOTEXIST = 42", }); - const result = await swift_package_buildLogic( + const { result } = await runSwiftPackageBuildLogic( { packagePath: '/test/package', }, executor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "Error: Swift package build failed\nDetails: main.swift:10:25: error: cannot find type 'DOESNOTEXIST' in scope\nlet broken: DOESNOTEXIST = 42", - }, - ], - isError: true, - }); + expect(result.isError()).toBe(true); + const text = result.text(); + expect(text).toContain('Swift package build failed'); + expect(text).toContain("cannot find type 'DOESNOTEXIST' in scope"); }); it('should handle spawn error', async () => { @@ -259,22 +238,17 @@ describe('swift_package_build plugin', () => { throw new Error('spawn ENOENT'); }; - const result = await swift_package_buildLogic( + const { result } = await runSwiftPackageBuildLogic( { packagePath: '/test/package', }, executor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to execute swift build\nDetails: spawn ENOENT', - }, - ], - isError: true, - }); + expect(result.isError()).toBe(true); + const text = result.text(); + expect(text).toContain('Failed to execute swift build'); + expect(text).toContain('spawn ENOENT'); }); it('should handle successful build with parameters', async () => { @@ -283,7 +257,7 @@ describe('swift_package_build plugin', () => { output: 'Build complete.', }); - const result = await swift_package_buildLogic( + const { result } = await runSwiftPackageBuildLogic( { packagePath: '/test/package', targetName: 'MyTarget', @@ -294,17 +268,7 @@ describe('swift_package_build plugin', () => { executor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '✅ Swift package build succeeded.' }, - { - type: 'text', - text: '💡 Next: Run tests with swift_package_test or execute with swift_package_run', - }, - { type: 'text', text: 'Build complete.' }, - ], - isError: false, - }); + expect(result.isError()).toBeFalsy(); }); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts index f739054a..61e4ebd3 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for swift_package_clean plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import { createMockExecutor, @@ -13,6 +7,8 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, swift_package_cleanLogic } from '../swift_package_clean.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('swift_package_clean plugin', () => { describe('Export Field Validation (Literal)', () => { @@ -49,11 +45,13 @@ describe('swift_package_clean plugin', () => { }); }; - await swift_package_cleanLogic( - { - packagePath: '/test/package', - }, - mockExecutor, + await runLogic(() => + swift_package_cleanLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ), ); expect(calls).toHaveLength(1); @@ -68,21 +66,23 @@ describe('swift_package_clean plugin', () => { describe('Response Logic Testing', () => { it('should handle valid params without validation errors in logic function', async () => { - // Note: The logic function assumes valid params since createTypedTool handles validation const mockExecutor = createMockExecutor({ success: true, output: 'Package cleaned successfully', }); - const result = await swift_package_cleanLogic( - { - packagePath: '/test/package', - }, - mockExecutor, + const result = await runLogic(() => + swift_package_cleanLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ), ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toBe('✅ Swift package cleaned successfully.'); + expect(result.isError).toBeUndefined(); + const text = allText(result); + expect(text).toContain('Swift package cleaned successfully'); }); it('should return successful clean response', async () => { @@ -91,24 +91,20 @@ describe('swift_package_clean plugin', () => { output: 'Package cleaned successfully', }); - const result = await swift_package_cleanLogic( - { - packagePath: '/test/package', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: '✅ Swift package cleaned successfully.' }, + const result = await runLogic(() => + swift_package_cleanLogic( { - type: 'text', - text: '💡 Build artifacts and derived data removed. Ready for fresh build.', + packagePath: '/test/package', }, - { type: 'text', text: 'Package cleaned successfully' }, - ], - isError: false, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBeUndefined(); + const text = allText(result); + expect(text).toContain('Swift Package Clean'); + expect(text).toContain('Swift package cleaned successfully'); + expect(text).toContain('Package cleaned successfully'); }); it('should return successful clean response with no output', async () => { @@ -117,24 +113,19 @@ describe('swift_package_clean plugin', () => { output: '', }); - const result = await swift_package_cleanLogic( - { - packagePath: '/test/package', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: '✅ Swift package cleaned successfully.' }, + const result = await runLogic(() => + swift_package_cleanLogic( { - type: 'text', - text: '💡 Build artifacts and derived data removed. Ready for fresh build.', + packagePath: '/test/package', }, - { type: 'text', text: '(clean completed silently)' }, - ], - isError: false, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBeUndefined(); + const text = allText(result); + expect(text).toContain('Swift Package Clean'); + expect(text).toContain('Swift package cleaned successfully'); }); it('should return error response for clean failure', async () => { @@ -143,22 +134,19 @@ describe('swift_package_clean plugin', () => { error: 'Permission denied', }); - const result = await swift_package_cleanLogic( - { - packagePath: '/test/package', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + swift_package_cleanLogic( { - type: 'text', - text: 'Error: Swift package clean failed\nDetails: Permission denied', + packagePath: '/test/package', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Swift package clean failed'); + expect(text).toContain('Permission denied'); }); it('should handle spawn error', async () => { @@ -166,22 +154,19 @@ describe('swift_package_clean plugin', () => { throw new Error('spawn ENOENT'); }; - const result = await swift_package_cleanLogic( - { - packagePath: '/test/package', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + swift_package_cleanLogic( { - type: 'text', - text: 'Error: Failed to execute swift package clean\nDetails: spawn ENOENT', + packagePath: '/test/package', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Failed to execute swift package clean'); + expect(text).toContain('spawn ENOENT'); }); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts index 32a0b22c..66e71019 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts @@ -1,397 +1,65 @@ -/** - * Tests for swift_package_list plugin - * Following CLAUDE.md testing standards with literal validation - * Using pure dependency injection for deterministic testing - */ - -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { schema, handler, swift_package_listLogic } from '../swift_package_list.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; -describe('swift_package_list plugin', () => { - // No mocks to clear with pure dependency injection +describe('swift_package_list plugin', () => { describe('Export Field Validation (Literal)', () => { it('should have handler function', () => { expect(typeof handler).toBe('function'); }); it('should validate schema correctly', () => { - // The schema is an empty object, so any input should be valid expect(typeof schema).toBe('object'); expect(Object.keys(schema)).toEqual([]); }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { + describe('Handler Behavior', () => { it('should return empty list when no processes are running', async () => { - // Create empty mock process map - const mockProcessMap = new Map(); - - // Use pure dependency injection with stub functions - const mockArrayFrom = () => []; - const mockDateNow = () => Date.now(); - - const result = await swift_package_listLogic( - {}, - { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, - { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, - ], - }); - }); - - it('should handle empty args object', async () => { - // Create empty mock process map - const mockProcessMap = new Map(); - - // Use pure dependency injection with stub functions - const mockArrayFrom = () => []; - const mockDateNow = () => Date.now(); - - const result = await swift_package_listLogic( - {}, - { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, - { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, - ], - }); - }); - - it('should handle null args', async () => { - // Create empty mock process map - const mockProcessMap = new Map(); - - // Use pure dependency injection with stub functions - const mockArrayFrom = () => []; - const mockDateNow = () => Date.now(); - - const result = await swift_package_listLogic(null, { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }); - - expect(result).toEqual({ - content: [ - { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, - { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, - ], - }); - }); - - it('should handle undefined args', async () => { - // Create empty mock process map - const mockProcessMap = new Map(); - - // Use pure dependency injection with stub functions - const mockArrayFrom = () => []; - const mockDateNow = () => Date.now(); - - const result = await swift_package_listLogic(undefined, { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }); - - expect(result).toEqual({ - content: [ - { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, - { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, - ], - }); - }); - - it('should handle args with extra properties', async () => { - // Create empty mock process map - const mockProcessMap = new Map(); - - // Use pure dependency injection with stub functions - const mockArrayFrom = () => []; - const mockDateNow = () => Date.now(); - - const result = await swift_package_listLogic( - { - extraProperty: 'value', - anotherProperty: 123, - }, - { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }, + const result = await runLogic(() => + swift_package_listLogic( + {}, + { + processMap: new Map(), + arrayFrom: () => [], + dateNow: () => Date.now(), + }, + ), ); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, - { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, - ], - }); + expect(result.isError).toBeUndefined(); + expect(allText(result)).toContain('No Swift Package processes currently running'); }); - it('should return single process when one process is running', async () => { + it('should use default executable name and clamp durations to at least one second', async () => { const startedAt = new Date('2023-01-01T10:00:00.000Z'); - const mockProcess = { - executableName: 'MyApp', - packagePath: '/test/package', - startedAt: startedAt, - }; - - // Create mock process map with one process - const mockProcessMap = new Map([[12345, mockProcess]]); - - // Use pure dependency injection with stub functions - const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime() + 5000; // 5 seconds after start - - const result = await swift_package_listLogic( - {}, - { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: '📋 Active Swift Package processes (1):' }, - { type: 'text', text: ' • PID 12345: MyApp (/test/package) - running 5s' }, - { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, - ], - }); - }); - - it('should return multiple processes when several are running', async () => { - const startedAt1 = new Date('2023-01-01T10:00:00.000Z'); - const startedAt2 = new Date('2023-01-01T10:00:07.000Z'); - - const mockProcess1 = { - executableName: 'MyApp', - packagePath: '/test/package1', - startedAt: startedAt1, - }; - - const mockProcess2 = { - executableName: undefined, // Test default executable name - packagePath: '/test/package2', - startedAt: startedAt2, - }; - - // Create mock process map with multiple processes - const mockProcessMap = new Map< - number, - { executableName?: string; packagePath: string; startedAt: Date } - >([ - [12345, mockProcess1], - [12346, mockProcess2], - ]); - - // Use pure dependency injection with stub functions - const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt1.getTime() + 10000; // 10 seconds after first start - - const result = await swift_package_listLogic( - {}, - { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: '📋 Active Swift Package processes (2):' }, - { type: 'text', text: ' • PID 12345: MyApp (/test/package1) - running 10s' }, - { type: 'text', text: ' • PID 12346: default (/test/package2) - running 3s' }, - { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, - ], - }); - }); - - it('should handle process with missing executableName', async () => { - const startedAt = new Date('2023-01-01T10:00:00.000Z'); - const mockProcess = { - executableName: undefined, // Test missing executable name - packagePath: '/test/package', - startedAt: startedAt, - }; - - // Create mock process map with one process - const mockProcessMap = new Map< - number, - { executableName?: string; packagePath: string; startedAt: Date } - >([[12345, mockProcess]]); - - // Use pure dependency injection with stub functions - const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime() + 1000; // 1 second after start - - const result = await swift_package_listLogic( - {}, - { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: '📋 Active Swift Package processes (1):' }, - { type: 'text', text: ' • PID 12345: default (/test/package) - running 1s' }, - { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, - ], - }); - }); - - it('should handle process with empty string executableName', async () => { - const startedAt = new Date('2023-01-01T10:00:00.000Z'); - const mockProcess = { - executableName: '', // Test empty string executable name - packagePath: '/test/package', - startedAt: startedAt, - }; - - // Create mock process map with one process - const mockProcessMap = new Map([[12345, mockProcess]]); - - // Use pure dependency injection with stub functions - const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime() + 2000; // 2 seconds after start - - const result = await swift_package_listLogic( - {}, - { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: '📋 Active Swift Package processes (1):' }, - { type: 'text', text: ' • PID 12345: default (/test/package) - running 2s' }, - { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, - ], - }); - }); - - it('should handle very recent process (less than 1 second)', async () => { - const startedAt = new Date('2023-01-01T10:00:00.000Z'); - const mockProcess = { - executableName: 'FastApp', - packagePath: '/test/package', - startedAt: startedAt, - }; - - // Create mock process map with one process - const mockProcessMap = new Map([[12345, mockProcess]]); - - // Use pure dependency injection with stub functions - const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime() + 500; // 500ms after start - - const result = await swift_package_listLogic( - {}, - { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: '📋 Active Swift Package processes (1):' }, - { type: 'text', text: ' • PID 12345: FastApp (/test/package) - running 1s' }, - { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, - ], - }); - }); - - it('should handle process running for exactly 0 milliseconds', async () => { - const startedAt = new Date('2023-01-01T10:00:00.000Z'); - const mockProcess = { - executableName: 'InstantApp', - packagePath: '/test/package', - startedAt: startedAt, - }; - - // Create mock process map with one process - const mockProcessMap = new Map([[12345, mockProcess]]); - - // Use pure dependency injection with stub functions - const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime(); // Same time as start - - const result = await swift_package_listLogic( - {}, - { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }, - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: '📋 Active Swift Package processes (1):' }, - { type: 'text', text: ' • PID 12345: InstantApp (/test/package) - running 1s' }, - { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, - ], - }); - }); - - it('should handle process running for a long time', async () => { - const startedAt = new Date('2023-01-01T10:00:00.000Z'); - const mockProcess = { - executableName: 'LongRunningApp', - packagePath: '/test/package', - startedAt: startedAt, - }; - - // Create mock process map with one process - const mockProcessMap = new Map([[12345, mockProcess]]); - - // Use pure dependency injection with stub functions - const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); - const mockDateNow = () => startedAt.getTime() + 7200000; // 2 hours later - - const result = await swift_package_listLogic( - {}, - { - processMap: mockProcessMap, - arrayFrom: mockArrayFrom, - dateNow: mockDateNow, - }, + const result = await runLogic(() => + swift_package_listLogic( + {}, + { + processMap: new Map([ + [ + 12345, + { + executableName: undefined, + packagePath: '/test/package', + startedAt, + }, + ], + ]), + arrayFrom: Array.from, + dateNow: () => startedAt.getTime(), + }, + ), ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '📋 Active Swift Package processes (1):' }, - { type: 'text', text: ' • PID 12345: LongRunningApp (/test/package) - running 7200s' }, - { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = allText(result); + expect(text).toContain('12345'); + expect(text).toContain('default'); + expect(text).toContain('/test/package'); + expect(text).toContain('1s'); }); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts index 0e55cb84..51b3ad9a 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for swift_package_run plugin - * Following CLAUDE.md testing standards with literal validation - * Integration tests using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -11,9 +5,15 @@ import { createNoopExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; +import { runToolLogic } from '../../../../test-utils/test-helpers.ts'; import { schema, handler, swift_package_runLogic } from '../swift_package_run.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; +const runSwiftPackageRunLogic = ( + params: Parameters[0], + executor: Parameters[1], +) => runToolLogic(() => swift_package_runLogic(params, executor)); + describe('swift_package_run plugin', () => { describe('Export Field Validation (Literal)', () => { it('should have handler function', () => { @@ -89,19 +89,16 @@ describe('swift_package_run plugin', () => { ); }; - await swift_package_runLogic( + await runSwiftPackageRunLogic( { packagePath: '/test/package', }, mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: ['swift', 'run', '--package-path', '/test/package'], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual(['swift', 'run', '--package-path', '/test/package']); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); it('should build correct command with release configuration', async () => { @@ -116,7 +113,7 @@ describe('swift_package_run plugin', () => { ); }; - await swift_package_runLogic( + await runSwiftPackageRunLogic( { packagePath: '/test/package', configuration: 'release', @@ -124,12 +121,16 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: ['swift', 'run', '--package-path', '/test/package', '-c', 'release'], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual([ + 'swift', + 'run', + '--package-path', + '/test/package', + '-c', + 'release', + ]); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); it('should build correct command with executable name', async () => { @@ -144,7 +145,7 @@ describe('swift_package_run plugin', () => { ); }; - await swift_package_runLogic( + await runSwiftPackageRunLogic( { packagePath: '/test/package', executableName: 'MyApp', @@ -152,12 +153,15 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: ['swift', 'run', '--package-path', '/test/package', 'MyApp'], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual([ + 'swift', + 'run', + '--package-path', + '/test/package', + 'MyApp', + ]); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); it('should build correct command with arguments', async () => { @@ -172,7 +176,7 @@ describe('swift_package_run plugin', () => { ); }; - await swift_package_runLogic( + await runSwiftPackageRunLogic( { packagePath: '/test/package', arguments: ['arg1', 'arg2'], @@ -180,12 +184,17 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: ['swift', 'run', '--package-path', '/test/package', '--', 'arg1', 'arg2'], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual([ + 'swift', + 'run', + '--package-path', + '/test/package', + '--', + 'arg1', + 'arg2', + ]); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); it('should build correct command with parseAsLibrary flag', async () => { @@ -200,7 +209,7 @@ describe('swift_package_run plugin', () => { ); }; - await swift_package_runLogic( + await runSwiftPackageRunLogic( { packagePath: '/test/package', parseAsLibrary: true, @@ -208,19 +217,16 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: [ - 'swift', - 'run', - '--package-path', - '/test/package', - '-Xswiftc', - '-parse-as-library', - ], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual([ + 'swift', + 'run', + '--package-path', + '/test/package', + '-Xswiftc', + '-parse-as-library', + ]); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); it('should build correct command with all parameters', async () => { @@ -235,7 +241,7 @@ describe('swift_package_run plugin', () => { ); }; - await swift_package_runLogic( + await runSwiftPackageRunLogic( { packagePath: '/test/package', executableName: 'MyApp', @@ -246,31 +252,36 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(executorCalls[0]).toEqual({ - command: [ - 'swift', - 'run', - '--package-path', - '/test/package', - '-c', - 'release', - '-Xswiftc', - '-parse-as-library', - 'MyApp', - '--', - 'arg1', - ], - logPrefix: 'Swift Package Run', - useShell: false, - opts: undefined, - }); + expect(executorCalls[0].command).toEqual([ + 'swift', + 'run', + '--package-path', + '/test/package', + '-c', + 'release', + '-Xswiftc', + '-parse-as-library', + 'MyApp', + '--', + 'arg1', + ]); + expect(executorCalls[0].logPrefix).toBe('Swift Package Run'); + expect(executorCalls[0].useShell).toBe(false); }); - it('should not call executor for background mode', async () => { - // For background mode, no executor should be called since it uses direct spawn - const mockExecutor = createNoopExecutor(); + it('should call executor for background mode with detached flag', async () => { + const mockExecutor: CommandExecutor = (command, logPrefix, useShell, opts, detached) => { + executorCalls.push({ command, logPrefix, useShell, opts, detached }); + return Promise.resolve( + createMockCommandResponse({ + success: true, + output: '', + error: undefined, + }), + ); + }; - const result = await swift_package_runLogic( + const { result } = await runSwiftPackageRunLogic( { packagePath: '/test/package', background: true, @@ -278,31 +289,29 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - // Should return success without calling executor - expect(result.content[0].text).toContain('🚀 Started executable in background'); + expect(executorCalls.length).toBeGreaterThan(0); + expect(executorCalls[0].detached).toBe(true); + const text = result.text(); + expect(text).toContain('Started executable in background'); }); }); describe('Response Logic Testing', () => { it('should return validation error for missing packagePath', async () => { - // Since the tool now uses createTypedTool, Zod validation happens at the handler level - // Test the handler directly to see Zod validation const result = await handler({}); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\npackagePath: Invalid input: expected string, received undefined', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Parameter validation failed'); + expect(text).toContain('packagePath'); }); it('should return success response for background mode', async () => { - const mockExecutor = createNoopExecutor(); - const result = await swift_package_runLogic( + const mockExecutor = createMockExecutor({ + success: true, + output: '', + }); + const { result } = await runSwiftPackageRunLogic( { packagePath: '/test/package', background: true, @@ -310,8 +319,8 @@ describe('swift_package_run plugin', () => { mockExecutor, ); - expect(result.content[0].text).toContain('🚀 Started executable in background'); - expect(result.content[0].text).toContain('💡 Process is running independently'); + const text = result.text(); + expect(text).toContain('Started executable in background'); }); it('should return success response for successful execution', async () => { @@ -320,20 +329,14 @@ describe('swift_package_run plugin', () => { output: 'Hello, World!', }); - const result = await swift_package_runLogic( + const { result } = await runSwiftPackageRunLogic( { packagePath: '/test/package', }, mockExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '✅ Swift executable completed successfully.' }, - { type: 'text', text: '💡 Process finished cleanly. Check output for results.' }, - { type: 'text', text: 'Hello, World!' }, - ], - }); + expect(result.isError()).toBeFalsy(); }); it('should return error response for failed execution', async () => { @@ -343,41 +346,30 @@ describe('swift_package_run plugin', () => { error: 'Compilation failed', }); - const result = await swift_package_runLogic( + const { result } = await runSwiftPackageRunLogic( { packagePath: '/test/package', }, mockExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '❌ Swift executable failed.' }, - { type: 'text', text: '(no output)' }, - { type: 'text', text: 'Errors:\nCompilation failed' }, - ], - }); + expect(result.isError()).toBe(true); }); it('should handle executor error', async () => { const mockExecutor = createMockExecutor(new Error('Command not found')); - const result = await swift_package_runLogic( + const { result } = await runSwiftPackageRunLogic( { packagePath: '/test/package', }, mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to execute swift run\nDetails: Command not found', - }, - ], - isError: true, - }); + expect(result.isError()).toBe(true); + const text = result.text(); + expect(text).toContain('Failed to execute swift run'); + expect(text).toContain('Command not found'); }); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts index c7e69e52..0edfb7a4 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + + import { schema, handler, @@ -20,22 +23,18 @@ describe('swift_package_stop plugin', () => { describe('Handler Behavior', () => { it('returns not-found response when process is missing', async () => { - const result = await swift_package_stopLogic( - { pid: 99999 }, - createMockProcessManager({ - getProcess: () => undefined, - }), + const result = await runLogic(() => + swift_package_stopLogic( + { pid: 99999 }, + createMockProcessManager({ + getProcess: () => undefined, + }), + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '⚠️ No running process found with PID 99999. Use swift_package_run to check active processes.', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('No running process found with PID 99999'); }); it('returns success response when termination succeeds', async () => { @@ -45,65 +44,55 @@ describe('swift_package_stop plugin', () => { startedAt, })); - const result = await swift_package_stopLogic( - { pid: 12345 }, - createMockProcessManager({ - getProcess: () => ({ - process: { - kill: () => undefined, - on: () => undefined, - pid: 12345, - }, - startedAt, + const result = await runLogic(() => + swift_package_stopLogic( + { pid: 12345 }, + createMockProcessManager({ + getProcess: () => ({ + process: { + kill: () => undefined, + on: () => undefined, + pid: 12345, + }, + startedAt, + }), + terminateTrackedProcess, }), - terminateTrackedProcess, - }), + ), ); expect(terminateTrackedProcess).toHaveBeenCalledWith(12345, 5000); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ Stopped executable (was running since 2023-01-01T10:00:00.000Z)', - }, - { - type: 'text', - text: '💡 Process terminated. You can now run swift_package_run again if needed.', - }, - ], - }); + expect(result.isError).toBeUndefined(); + const text = allText(result); + expect(text).toContain('Stopped executable (was running since 2023-01-01T10:00:00.000Z)'); }); it('returns error response when termination reports an error', async () => { const startedAt = new Date('2023-01-01T10:00:00.000Z'); - const result = await swift_package_stopLogic( - { pid: 54321 }, - createMockProcessManager({ - getProcess: () => ({ - process: { - kill: () => undefined, - on: () => undefined, - pid: 54321, - }, - startedAt, - }), - terminateTrackedProcess: async () => ({ - status: 'terminated', - error: 'ESRCH: No such process', + const result = await runLogic(() => + swift_package_stopLogic( + { pid: 54321 }, + createMockProcessManager({ + getProcess: () => ({ + process: { + kill: () => undefined, + on: () => undefined, + pid: 54321, + }, + startedAt, + }), + terminateTrackedProcess: async () => ({ + status: 'terminated', + error: 'ESRCH: No such process', + }), }), - }), + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to stop process\nDetails: ESRCH: No such process', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('Failed to stop process'); + expect(text).toContain('ESRCH: No such process'); }); it('uses custom timeout when provided', async () => { @@ -113,20 +102,22 @@ describe('swift_package_stop plugin', () => { startedAt, })); - await swift_package_stopLogic( - { pid: 12345 }, - createMockProcessManager({ - getProcess: () => ({ - process: { - kill: () => undefined, - on: () => undefined, - pid: 12345, - }, - startedAt, + await runLogic(() => + swift_package_stopLogic( + { pid: 12345 }, + createMockProcessManager({ + getProcess: () => ({ + process: { + kill: () => undefined, + on: () => undefined, + pid: 12345, + }, + startedAt, + }), + terminateTrackedProcess, }), - terminateTrackedProcess, - }), - 10, + 10, + ), ); expect(terminateTrackedProcess).toHaveBeenCalledWith(12345, 10); @@ -136,7 +127,8 @@ describe('swift_package_stop plugin', () => { const result = await handler({ pid: 'bad' }); expect(result.isError).toBe(true); - expect(result.content[0]?.text).toContain('Parameter validation failed'); + const text = allText(result); + expect(text).toContain('Parameter validation failed'); }); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts index d8a6a13a..1bbd41a3 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts @@ -1,20 +1,19 @@ -/** - * Tests for swift_package_test plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, - createMockFileSystemExecutor, - createNoopExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; +import { runToolLogic } from '../../../../test-utils/test-helpers.ts'; import { schema, handler, swift_package_testLogic } from '../swift_package_test.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; +const runSwiftPackageTestLogic = ( + params: Parameters[0], + executor: Parameters[1], +) => runToolLogic(() => swift_package_testLogic(params, executor)); + describe('swift_package_test plugin', () => { describe('Export Field Validation (Literal)', () => { it('should have handler function', () => { @@ -68,14 +67,9 @@ describe('swift_package_test plugin', () => { describe('Command Generation Testing', () => { it('should build correct command for basic test', async () => { - const calls: Array<{ - args: string[]; - name?: string; - hideOutput?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - const mockExecutor: CommandExecutor = async (args, name, hideOutput, opts) => { - calls.push({ args, name, hideOutput, opts }); + const calls: Array<{ args: string[] }> = []; + const mockExecutor: CommandExecutor = async (args, _name, _hideOutput, _opts) => { + calls.push({ args }); return createMockCommandResponse({ success: true, output: 'Test Passed', @@ -83,7 +77,7 @@ describe('swift_package_test plugin', () => { }); }; - await swift_package_testLogic( + await runSwiftPackageTestLogic( { packagePath: '/test/package', }, @@ -91,23 +85,13 @@ describe('swift_package_test plugin', () => { ); expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ - args: ['swift', 'test', '--package-path', '/test/package'], - name: 'Swift Package Test', - hideOutput: false, - opts: undefined, - }); + expect(calls[0].args).toEqual(['swift', 'test', '--package-path', '/test/package']); }); it('should build correct command with all parameters', async () => { - const calls: Array<{ - args: string[]; - name?: string; - hideOutput?: boolean; - opts?: { env?: Record; cwd?: string }; - }> = []; - const mockExecutor: CommandExecutor = async (args, name, hideOutput, opts) => { - calls.push({ args, name, hideOutput, opts }); + const calls: Array<{ args: string[] }> = []; + const mockExecutor: CommandExecutor = async (args, _name, _hideOutput, _opts) => { + calls.push({ args }); return createMockCommandResponse({ success: true, output: 'Tests completed', @@ -115,7 +99,7 @@ describe('swift_package_test plugin', () => { }); }; - await swift_package_testLogic( + await runSwiftPackageTestLogic( { packagePath: '/test/package', testProduct: 'MyTests', @@ -129,69 +113,38 @@ describe('swift_package_test plugin', () => { ); expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ - args: [ - 'swift', - 'test', - '--package-path', - '/test/package', - '-c', - 'release', - '--test-product', - 'MyTests', - '--filter', - 'Test.*', - '--no-parallel', - '--show-code-coverage', - '-Xswiftc', - '-parse-as-library', - ], - name: 'Swift Package Test', - hideOutput: false, - opts: undefined, - }); + expect(calls[0].args).toEqual([ + 'swift', + 'test', + '--package-path', + '/test/package', + '-c', + 'release', + '--test-product', + 'MyTests', + '--filter', + 'Test.*', + '--no-parallel', + '--show-code-coverage', + '-Xswiftc', + '-parse-as-library', + ]); }); }); describe('Response Logic Testing', () => { - it('should handle empty packagePath parameter', async () => { - // When packagePath is empty, the function should still process it - // but the command execution may fail, which is handled by the executor - const mockExecutor = createMockExecutor({ - success: true, - output: 'Tests completed with empty path', - }); - - const result = await swift_package_testLogic({ packagePath: '' }, mockExecutor); - - expect(result.isError).toBe(false); - expect(result.content[0].text).toBe('✅ Swift package tests completed.'); - }); - - it('should return successful test response', async () => { + it('should return non-error for successful tests', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'All tests passed.', }); - const result = await swift_package_testLogic( - { - packagePath: '/test/package', - }, + const { result } = await runSwiftPackageTestLogic( + { packagePath: '/test/package' }, mockExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '✅ Swift package tests completed.' }, - { - type: 'text', - text: '💡 Next: Execute your app with swift_package_run if tests passed', - }, - { type: 'text', text: 'All tests passed.' }, - ], - isError: false, - }); + expect(result.isError()).toBeFalsy(); }); it('should return error response for test failure', async () => { @@ -200,48 +153,12 @@ describe('swift_package_test plugin', () => { error: '2 tests failed', }); - const result = await swift_package_testLogic( - { - packagePath: '/test/package', - }, + const { result } = await runSwiftPackageTestLogic( + { packagePath: '/test/package' }, mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Swift package tests failed\nDetails: 2 tests failed', - }, - ], - isError: true, - }); - }); - - it('should include stdout diagnostics when stderr is empty on test failure', async () => { - const mockExecutor = createMockExecutor({ - success: false, - error: '', - output: - "main.swift:10:25: error: cannot find type 'DOESNOTEXIST' in scope\nlet broken: DOESNOTEXIST = 42", - }); - - const result = await swift_package_testLogic( - { - packagePath: '/test/package', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "Error: Swift package tests failed\nDetails: main.swift:10:25: error: cannot find type 'DOESNOTEXIST' in scope\nlet broken: DOESNOTEXIST = 42", - }, - ], - isError: true, - }); + expect(result.isError()).toBe(true); }); it('should handle spawn error', async () => { @@ -249,54 +166,28 @@ describe('swift_package_test plugin', () => { throw new Error('spawn ENOENT'); }; - const result = await swift_package_testLogic( - { - packagePath: '/test/package', - }, + const { result } = await runSwiftPackageTestLogic( + { packagePath: '/test/package' }, mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Failed to execute swift test\nDetails: spawn ENOENT', - }, - ], - isError: true, - }); + expect(result.isError()).toBe(true); + const text = result.text(); + expect(text).toContain('Failed to execute swift test'); + expect(text).toContain('spawn ENOENT'); }); - it('should handle successful test with parameters', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Tests completed.', - }); + it('should return error for invalid configuration', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); - const result = await swift_package_testLogic( - { - packagePath: '/test/package', - testProduct: 'MyTests', - filter: 'Test.*', - configuration: 'release', - parallel: false, - showCodecov: true, - parseAsLibrary: true, - }, + const { result } = await runSwiftPackageTestLogic( + { packagePath: '/test/package', configuration: 'invalid' as 'debug' }, mockExecutor, ); - expect(result).toEqual({ - content: [ - { type: 'text', text: '✅ Swift package tests completed.' }, - { - type: 'text', - text: '💡 Next: Execute your app with swift_package_run if tests passed', - }, - { type: 'text', text: 'Tests completed.' }, - ], - isError: false, - }); + expect(result.isError()).toBe(true); + const text = result.text(); + expect(text).toContain('Invalid configuration'); }); }); }); diff --git a/src/mcp/tools/swift-package/swift_package_build.ts b/src/mcp/tools/swift-package/swift_package_build.ts index 2dd87d93..f21ac745 100644 --- a/src/mcp/tools/swift-package/swift_package_build.ts +++ b/src/mcp/tools/swift-package/swift_package_build.ts @@ -1,16 +1,19 @@ import * as z from 'zod'; import path from 'node:path'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { createXcodebuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import type { StartedPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { finalizeInlineXcodebuild } from '../../../utils/xcodebuild-output.ts'; -// Define schema as ZodObject const baseSchemaObject = z.object({ packagePath: z.string(), targetName: z.string().optional(), @@ -25,13 +28,13 @@ const publicSchemaObject = baseSchemaObject.omit({ const swiftPackageBuildSchema = baseSchemaObject; -// Use z.infer for type safety type SwiftPackageBuildParams = z.infer; export async function swift_package_buildLogic( params: SwiftPackageBuildParams, executor: CommandExecutor, -): Promise { +): Promise { + const ctx = getHandlerContext(); const resolvedPath = path.resolve(params.packagePath); const swiftArgs = ['build', '--package-path', resolvedPath]; @@ -54,29 +57,64 @@ export async function swift_package_buildLogic( } log('info', `Running swift ${swiftArgs.join(' ')}`); - try { - const result = await executor(['swift', ...swiftArgs], 'Swift Package Build', false, undefined); - if (!result.success) { - const errorMessage = result.error || result.output || 'Unknown error'; - return createErrorResponse('Swift package build failed', errorMessage); - } - return { - content: [ - { type: 'text', text: '✅ Swift package build succeeded.' }, - { - type: 'text', - text: '💡 Next: Run tests with swift_package_test or execute with swift_package_run', - }, - { type: 'text', text: result.output }, - ], - isError: false, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log('error', `Swift package build failed: ${message}`); - return createErrorResponse('Failed to execute swift build', message); - } + const headerEvent = header('Swift Package Build', [ + { label: 'Package', value: resolvedPath }, + ...(params.targetName ? [{ label: 'Target', value: params.targetName }] : []), + ...(params.configuration ? [{ label: 'Configuration', value: params.configuration }] : []), + ]); + + const pipeline = createXcodebuildPipeline({ + operation: 'BUILD', + toolName: `build_spm`, + params: {}, + emit: ctx.emit, + }); + + pipeline.emitEvent(headerEvent); + const started: StartedPipeline = { pipeline, startedAt: Date.now() }; + + return withErrorHandling( + ctx, + async () => { + const result = await executor(['swift', ...swiftArgs], 'Swift Package Build', false, { + onStdout: (chunk: string) => pipeline.onStdout(chunk), + onStderr: (chunk: string) => pipeline.onStderr(chunk), + }); + + if (!result.success) { + const errorMessage = result.error || result.output || 'Unknown error'; + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + responseContent: [ + { + type: 'text', + text: `Swift package build failed: ${errorMessage}`, + }, + ], + }); + return; + } + + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: true, + durationMs: Date.now() - started.startedAt, + }); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to execute swift build: ${message}`, + logMessage: ({ message }) => `Swift package build failed: ${message}`, + mapError: ({ message, emit }) => { + emit?.(statusLine('error', `Failed to execute swift build: ${message}`)); + }, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/swift-package/swift_package_clean.ts b/src/mcp/tools/swift-package/swift_package_clean.ts index 1c2e8428..95ce5324 100644 --- a/src/mcp/tools/swift-package/swift_package_clean.ts +++ b/src/mcp/tools/swift-package/swift_package_clean.ts @@ -2,50 +2,52 @@ import * as z from 'zod'; import path from 'node:path'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; import { log } from '../../../utils/logging/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const swiftPackageCleanSchema = z.object({ packagePath: z.string(), }); -// Use z.infer for type safety type SwiftPackageCleanParams = z.infer; export async function swift_package_cleanLogic( params: SwiftPackageCleanParams, executor: CommandExecutor, -): Promise { +): Promise { + const ctx = getHandlerContext(); const resolvedPath = path.resolve(params.packagePath); const swiftArgs = ['package', '--package-path', resolvedPath, 'clean']; log('info', `Running swift ${swiftArgs.join(' ')}`); - try { - const result = await executor(['swift', ...swiftArgs], 'Swift Package Clean', false, undefined); - if (!result.success) { - const errorMessage = result.error || result.output || 'Unknown error'; - return createErrorResponse('Swift package clean failed', errorMessage); - } - - return { - content: [ - { type: 'text', text: '✅ Swift package cleaned successfully.' }, - { - type: 'text', - text: '💡 Build artifacts and derived data removed. Ready for fresh build.', - }, - { type: 'text', text: result.output || '(clean completed silently)' }, - ], - isError: false, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log('error', `Swift package clean failed: ${message}`); - return createErrorResponse('Failed to execute swift package clean', message); - } + + const headerEvent = header('Swift Package Clean', [{ label: 'Package', value: resolvedPath }]); + + await withErrorHandling( + ctx, + async () => { + const result = await executor(['swift', ...swiftArgs], 'Swift Package Clean', false); + if (!result.success) { + const errorMessage = result.error || result.output || 'Unknown error'; + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Swift package clean failed: ${errorMessage}`)); + return; + } + + ctx.emit(headerEvent); + if (result.output) { + ctx.emit(section('Output', [result.output])); + } + ctx.emit(statusLine('success', 'Swift package cleaned successfully')); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to execute swift package clean: ${message}`, + logMessage: ({ message }) => `Swift package clean failed: ${message}`, + }, + ); } export const schema = swiftPackageCleanSchema.shape; diff --git a/src/mcp/tools/swift-package/swift_package_list.ts b/src/mcp/tools/swift-package/swift_package_list.ts index 42d14de0..5ef55c10 100644 --- a/src/mcp/tools/swift-package/swift_package_list.ts +++ b/src/mcp/tools/swift-package/swift_package_list.ts @@ -1,18 +1,9 @@ -// Note: This tool shares the activeProcesses map with swift_package_run -// Since both are in the same workflow directory, they can share state - -// Import the shared activeProcesses map from swift_package_run -// This maintains the same behavior as the original implementation import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor } from '../../../utils/command.ts'; import { activeProcesses } from './active-processes.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -/** - * Process list dependencies for dependency injection - */ type ListProcessInfo = { executableName?: string; packagePath?: string; @@ -25,16 +16,11 @@ export interface ProcessListDependencies { dateNow?: typeof Date.now; } -/** - * Swift package list business logic - extracted for testability and separation of concerns - * @param params - Parameters (unused, but maintained for consistency) - * @param dependencies - Injectable dependencies for testing - * @returns ToolResponse with process list information - */ export async function swift_package_listLogic( params?: unknown, dependencies?: ProcessListDependencies, -): Promise { +): Promise { + const ctx = getHandlerContext(); const processMap = dependencies?.processMap ?? new Map( @@ -52,45 +38,45 @@ export async function swift_package_listLogic( const processes = arrayFrom(processMap.entries()); + const headerEvent = header('Swift Package Processes'); + if (processes.length === 0) { - return { - content: [ - createTextContent('ℹ️ No Swift Package processes currently running.'), - createTextContent('💡 Use swift_package_run to start an executable.'), - ], - }; + ctx.emit(headerEvent); + ctx.emit(statusLine('info', 'No Swift Package processes currently running.')); + return; } - const content = [createTextContent(`📋 Active Swift Package processes (${processes.length}):`)]; + ctx.emit(headerEvent); - for (const [pid, info] of processes) { - // Use logical OR instead of nullish coalescing to treat empty strings as falsy + const cardLines: string[] = ['']; + for (const [pid, info] of processes as Array<[number, ListProcessInfo]>) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const executableName = info.executableName || 'default'; const runtime = Math.max(1, Math.round((dateNow() - info.startedAt.getTime()) / 1000)); const packagePath = info.packagePath ?? 'unknown package'; - content.push( - createTextContent(` • PID ${pid}: ${executableName} (${packagePath}) - running ${runtime}s`), + cardLines.push( + `\u{1F7E2} ${executableName}`, + ` PID: ${pid} | Uptime: ${runtime}s`, + ` Package: ${packagePath}`, + '', ); } - content.push(createTextContent('💡 Use swift_package_stop with a PID to terminate a process.')); + while (cardLines.at(-1) === '') { + cardLines.pop(); + } - return { content }; + ctx.emit(section(`Running Processes (${processes.length}):`, cardLines)); } -// Define schema as ZodObject (empty for this tool) const swiftPackageListSchema = z.object({}); -// Use z.infer for type safety type SwiftPackageListParams = z.infer; export const schema = swiftPackageListSchema.shape; export const handler = createTypedTool( swiftPackageListSchema, - (params: SwiftPackageListParams) => { - return swift_package_listLogic(params); - }, + (params: SwiftPackageListParams) => swift_package_listLogic(params), getDefaultCommandExecutor, ); diff --git a/src/mcp/tools/swift-package/swift_package_run.ts b/src/mcp/tools/swift-package/swift_package_run.ts index 451733a7..63a1a2e6 100644 --- a/src/mcp/tools/swift-package/swift_package_run.ts +++ b/src/mcp/tools/swift-package/swift_package_run.ts @@ -1,19 +1,25 @@ import * as z from 'zod'; import path from 'node:path'; -import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { log } from '../../../utils/logging/index.ts'; -import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import type { CommandExecutor, CommandResponse } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; import { addProcess } from './active-processes.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { acquireDaemonActivity } from '../../../daemon/activity-registry.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, section, detailTree } from '../../../utils/tool-event-builders.ts'; +import { createXcodebuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import type { StartedPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { + createBuildRunResultEvents, + finalizeInlineXcodebuild, +} from '../../../utils/xcodebuild-output.ts'; +import { displayPath } from '../../../utils/build-preflight.ts'; -// Define schema as ZodObject const baseSchemaObject = z.object({ packagePath: z.string(), executableName: z.string().optional(), @@ -28,27 +34,67 @@ const publicSchemaObject = baseSchemaObject.omit({ configuration: true, } as const); -const swiftPackageRunSchema = baseSchemaObject; +type SwiftPackageRunParams = z.infer; + +type SwiftPackageRunTimeoutResult = { + success: boolean; + output: string; + error: string; + timedOut: true; +}; + +function isTimedOutResult( + result: CommandResponse | SwiftPackageRunTimeoutResult, +): result is SwiftPackageRunTimeoutResult { + return 'timedOut' in result && result.timedOut; +} + +async function resolveExecutablePath( + executor: CommandExecutor, + packagePath: string, + executableName: string, + configuration?: SwiftPackageRunParams['configuration'], +): Promise { + const command = ['swift', 'build', '--package-path', packagePath, '--show-bin-path']; + if (configuration?.toLowerCase() === 'release') { + command.push('-c', 'release'); + } + + const result = await executor(command, 'Swift Package Run (Resolve Executable Path)', false); + if (!result.success) { + return null; + } -// Use z.infer for type safety -type SwiftPackageRunParams = z.infer; + const binPath = result.output.trim(); + if (!binPath) { + return null; + } + + return path.join(binPath, executableName); +} export async function swift_package_runLogic( params: SwiftPackageRunParams, executor: CommandExecutor, -): Promise { +): Promise { + const ctx = getHandlerContext(); const resolvedPath = path.resolve(params.packagePath); const timeout = Math.min(params.timeout ?? 30, 300) * 1000; // Convert to ms, max 5 minutes - // Detect test environment to prevent real spawn calls during testing - const isTestEnvironment = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'; - const swiftArgs = ['run', '--package-path', resolvedPath]; + const headerEvent = header('Swift Package Run', [ + { label: 'Package', value: resolvedPath }, + ...(params.executableName ? [{ label: 'Executable', value: params.executableName }] : []), + ...(params.background ? [{ label: 'Mode', value: 'background' }] : []), + ]); + if (params.configuration?.toLowerCase() === 'release') { swiftArgs.push('-c', 'release'); } else if (params.configuration && params.configuration.toLowerCase() !== 'debug') { - return createTextResponse("Invalid configuration. Use 'debug' or 'release'.", true); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', "Invalid configuration. Use 'debug' or 'release'.")); + return; } if (params.parseAsLibrary) { @@ -59,7 +105,6 @@ export async function swift_package_runLogic( swiftArgs.push(params.executableName); } - // Add double dash before executable arguments if (params.arguments && params.arguments.length > 0) { swiftArgs.push('--'); swiftArgs.push(...params.arguments); @@ -67,24 +112,11 @@ export async function swift_package_runLogic( log('info', `Running swift ${swiftArgs.join(' ')}`); - try { - if (params.background) { - // Background mode: Use CommandExecutor but don't wait for completion - if (isTestEnvironment) { - // In test environment, return mock response without real process - const mockPid = 12345; - return { - content: [ - createTextContent( - `🚀 Started executable in background (PID: ${mockPid})\n` + - `💡 Process is running independently. Use swift_package_stop with PID ${mockPid} to terminate when needed.`, - ), - ], - }; - } else { - // Production: use CommandExecutor to start the process + return withErrorHandling( + ctx, + async () => { + if (params.background) { const command = ['swift', ...swiftArgs]; - // Filter out undefined values from process.env const cleanEnv = Object.fromEntries( Object.entries(process.env).filter(([, value]) => value !== undefined), ) as Record; @@ -96,12 +128,10 @@ export async function swift_package_runLogic( true, ); - // Store the process in active processes system if available if (result.process?.pid) { addProcess(result.process.pid, { process: { kill: (signal?: string) => { - // Adapt string signal to NodeJS.Signals if (result.process) { result.process.kill(signal as NodeJS.Signals); } @@ -119,38 +149,47 @@ export async function swift_package_runLogic( releaseActivity: acquireDaemonActivity('swift-package.background-process'), }); - return { - content: [ - createTextContent( - `🚀 Started executable in background (PID: ${result.process.pid})\n` + - `💡 Process is running independently. Use swift_package_stop with PID ${result.process.pid} to terminate when needed.`, - ), - ], - }; - } else { - return { - content: [ - createTextContent( - `🚀 Started executable in background\n` + - `💡 Process is running independently. PID not available for this execution.`, - ), - ], - }; + ctx.emit(headerEvent); + ctx.emit( + statusLine('success', `Started executable in background (PID: ${result.process.pid})`), + ); + ctx.emit( + section('Next Steps', [ + `Use swift_package_stop with PID ${result.process.pid} to terminate when needed.`, + ]), + ); + return; } + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Started executable in background')); + ctx.emit(section('Next Steps', ['PID not available for this execution.'])); + return; } - } else { - // Foreground mode: use CommandExecutor but handle long-running processes + const command = ['swift', ...swiftArgs]; - // Create a promise that will either complete with the command result or timeout - const commandPromise = executor(command, 'Swift Package Run', false, undefined); + const pipeline = createXcodebuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_spm', + params: {}, + emit: ctx.emit, + }); + + pipeline.emitEvent(headerEvent); + const started: StartedPipeline = { pipeline, startedAt: Date.now() }; + + const stdoutChunks: string[] = []; + + const commandPromise = executor(command, 'Swift Package Run', false, { + onStdout: (chunk: string) => { + stdoutChunks.push(chunk); + pipeline.onStdout(chunk); + }, + onStderr: (chunk: string) => pipeline.onStderr(chunk), + }); - const timeoutPromise = new Promise<{ - success: boolean; - output: string; - error: string; - timedOut: boolean; - }>((resolve) => { + const timeoutPromise = new Promise((resolve) => { setTimeout(() => { resolve({ success: false, @@ -161,64 +200,67 @@ export async function swift_package_runLogic( }, timeout); }); - // Race between command completion and timeout const result = await Promise.race([commandPromise, timeoutPromise]); - if ('timedOut' in result && result.timedOut) { - // For timeout case, the process may still be running - provide timeout response - if (isTestEnvironment) { - // In test environment, return mock response - const mockPid = 12345; - return { - content: [ - createTextContent( - `⏱️ Process timed out after ${timeout / 1000} seconds but may continue running.`, - ), - createTextContent(`PID: ${mockPid} (mock)`), - createTextContent( - `💡 Process may still be running. Use swift_package_stop with PID ${mockPid} to terminate when needed.`, - ), - createTextContent(result.output || '(no output so far)'), - ], - }; - } else { - // Production: timeout occurred, but we don't start a new process - return { - content: [ - createTextContent(`⏱️ Process timed out after ${timeout / 1000} seconds.`), - createTextContent( - `💡 Process execution exceeded the timeout limit. Consider using background mode for long-running executables.`, - ), - createTextContent(result.output || '(no output so far)'), - ], - }; - } + if (isTimedOutResult(result)) { + const timeoutSeconds = timeout / 1000; + ctx.emit(headerEvent); + ctx.emit(statusLine('warning', `Process timed out after ${timeoutSeconds} seconds.`)); + ctx.emit( + section('Details', [ + 'Process execution exceeded the timeout limit. Consider using background mode for long-running executables.', + result.output || '(no output so far)', + ]), + ); + return; } - if (result.success) { - return { - content: [ - createTextContent('✅ Swift executable completed successfully.'), - createTextContent('💡 Process finished cleanly. Check output for results.'), - createTextContent(result.output || '(no output)'), - ], - }; - } else { - const content = [ - createTextContent('❌ Swift executable failed.'), - createTextContent(result.output || '(no output)'), - ]; - if (result.error) { - content.push(createTextContent(`Errors:\n${result.error}`)); - } - return { content }; - } - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log('error', `Swift run failed: ${message}`); - return createErrorResponse('Failed to execute swift run', message); - } + const capturedOutput = stdoutChunks.join('').trim(); + const resolvedExecutableName = params.executableName ?? path.basename(resolvedPath); + const executablePath = await resolveExecutablePath( + executor, + resolvedPath, + resolvedExecutableName, + params.configuration, + ); + const processId = result.process?.pid; + const buildRunEvents = + result.success && executablePath + ? createBuildRunResultEvents({ + scheme: resolvedExecutableName, + platform: 'Swift Package', + target: resolvedExecutableName, + appPath: executablePath, + processId, + buildLogPath: pipeline.logPath, + launchState: 'requested', + }) + : []; + const tailEvents = [ + ...buildRunEvents, + ...(result.success && !executablePath + ? [detailTree([{ label: 'Build Logs', value: displayPath(pipeline.logPath) }])] + : []), + ...(capturedOutput ? [section('Output', [capturedOutput])] : []), + ]; + + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: result.success, + durationMs: Date.now() - started.startedAt, + tailEvents, + emitSummary: true, + errorFallbackPolicy: 'if-no-structured-diagnostics', + includeBuildLogFileRef: false, + }); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to execute swift run: ${message}`, + logMessage: ({ message }) => `Swift run failed: ${message}`, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ @@ -227,7 +269,7 @@ export const schema = getSessionAwareToolSchemaShape({ }); export const handler = createSessionAwareTool({ - internalSchema: swiftPackageRunSchema, + internalSchema: baseSchemaObject, logicFunction: swift_package_runLogic, getExecutor: getDefaultCommandExecutor, }); diff --git a/src/mcp/tools/swift-package/swift_package_stop.ts b/src/mcp/tools/swift-package/swift_package_stop.ts index 3366a485..0ba8945c 100644 --- a/src/mcp/tools/swift-package/swift_package_stop.ts +++ b/src/mcp/tools/swift-package/swift_package_stop.ts @@ -1,7 +1,11 @@ import * as z from 'zod'; -import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { getProcess, terminateTrackedProcess, type ProcessInfo } from './active-processes.ts'; -import type { ToolResponse } from '../../../types/common.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; const swiftPackageStopSchema = z.object({ pid: z.number(), @@ -38,58 +42,66 @@ export async function swift_package_stopLogic( params: SwiftPackageStopParams, processManager: ProcessManager = getDefaultProcessManager(), timeout: number = 5000, -): Promise { +): Promise { + const ctx = getHandlerContext(); + const headerEvent = header('Swift Package Stop', [{ label: 'PID', value: String(params.pid) }]); + const processInfo = processManager.getProcess(params.pid); if (!processInfo) { - return createTextResponse( - `⚠️ No running process found with PID ${params.pid}. Use swift_package_run to check active processes.`, - true, + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'error', + `No running process found with PID ${params.pid}. Use swift_package_list to check active processes.`, + ), ); + return; } - try { - const result = await processManager.terminateTrackedProcess(params.pid, timeout); - if (result.status === 'not-found') { - return createTextResponse( - `⚠️ No running process found with PID ${params.pid}. Use swift_package_run to check active processes.`, - true, - ); - } + await withErrorHandling( + ctx, + async () => { + const result = await processManager.terminateTrackedProcess(params.pid, timeout); + if (result.status === 'not-found') { + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'error', + `No running process found with PID ${params.pid}. Use swift_package_list to check active processes.`, + ), + ); + return; + } - if (result.error) { - return createErrorResponse('Failed to stop process', result.error); - } + if (result.error) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to stop process: ${result.error}`)); + return; + } - const startedAt = result.startedAt ?? processInfo.startedAt; + const startedAt = result.startedAt ?? processInfo.startedAt; - return { - content: [ - { - type: 'text', - text: `✅ Stopped executable (was running since ${startedAt.toISOString()})`, - }, - { - type: 'text', - text: `💡 Process terminated. You can now run swift_package_run again if needed.`, - }, - ], - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Failed to stop process', message); - } + ctx.emit(headerEvent); + ctx.emit( + statusLine('success', `Stopped executable (was running since ${startedAt.toISOString()})`), + ); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to stop process: ${message}`, + }, + ); } export const schema = swiftPackageStopSchema.shape; -export async function handler(args: Record): Promise { - const parseResult = swiftPackageStopSchema.safeParse(args); - if (!parseResult.success) { - return createErrorResponse( - 'Parameter validation failed', - parseResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '), - ); - } - - return swift_package_stopLogic(parseResult.data); +interface SwiftPackageStopContext { + processManager: ProcessManager; } + +export const handler = createTypedToolWithContext( + swiftPackageStopSchema, + (params: SwiftPackageStopParams, ctx: SwiftPackageStopContext) => + swift_package_stopLogic(params, ctx.processManager), + () => ({ processManager: getDefaultProcessManager() }), +); diff --git a/src/mcp/tools/swift-package/swift_package_test.ts b/src/mcp/tools/swift-package/swift_package_test.ts index 8d022d02..8ad754be 100644 --- a/src/mcp/tools/swift-package/swift_package_test.ts +++ b/src/mcp/tools/swift-package/swift_package_test.ts @@ -2,15 +2,18 @@ import * as z from 'zod'; import path from 'node:path'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { log } from '../../../utils/logging/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { finalizeInlineXcodebuild } from '../../../utils/xcodebuild-output.ts'; +import { displayPath } from '../../../utils/build-preflight.ts'; -// Define schema as ZodObject const baseSchemaObject = z.object({ packagePath: z.string(), testProduct: z.string().optional(), @@ -27,20 +30,28 @@ const publicSchemaObject = baseSchemaObject.omit({ const swiftPackageTestSchema = baseSchemaObject; -// Use z.infer for type safety type SwiftPackageTestParams = z.infer; export async function swift_package_testLogic( params: SwiftPackageTestParams, executor: CommandExecutor, -): Promise { +): Promise { + const ctx = getHandlerContext(); const resolvedPath = path.resolve(params.packagePath); const swiftArgs = ['test', '--package-path', resolvedPath]; + const headerEvent = header('Swift Package Test', [ + { label: 'Package', value: resolvedPath }, + ...(params.testProduct ? [{ label: 'Test Product', value: params.testProduct }] : []), + ...(params.configuration ? [{ label: 'Configuration', value: params.configuration }] : []), + ]); + if (params.configuration?.toLowerCase() === 'release') { swiftArgs.push('-c', 'release'); } else if (params.configuration && params.configuration.toLowerCase() !== 'debug') { - return createTextResponse("Invalid configuration. Use 'debug' or 'release'.", true); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', "Invalid configuration. Use 'debug' or 'release'.")); + return; } if (params.testProduct) { @@ -64,29 +75,49 @@ export async function swift_package_testLogic( } log('info', `Running swift ${swiftArgs.join(' ')}`); - try { - const result = await executor(['swift', ...swiftArgs], 'Swift Package Test', false, undefined); - if (!result.success) { - const errorMessage = result.error || result.output || 'Unknown error'; - return createErrorResponse('Swift package tests failed', errorMessage); - } - - return { - content: [ - { type: 'text', text: '✅ Swift package tests completed.' }, - { - type: 'text', - text: '💡 Next: Execute your app with swift_package_run if tests passed', - }, - { type: 'text', text: result.output }, - ], - isError: false, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log('error', `Swift package test failed: ${message}`); - return createErrorResponse('Failed to execute swift test', message); - } + + const configText = `Swift Package Test\n Package: ${displayPath(resolvedPath)}`; + const started = startBuildPipeline({ + operation: 'TEST', + toolName: 'swift_package_test', + params: { + scheme: params.testProduct ?? path.basename(resolvedPath), + configuration: params.configuration ?? 'debug', + platform: 'Swift Package', + preflight: configText, + }, + message: configText, + }); + + const { pipeline } = started; + + return withErrorHandling( + ctx, + async () => { + const result = await executor(['swift', ...swiftArgs], 'Swift Package Test', false, { + onStdout: (chunk: string) => pipeline.onStdout(chunk), + onStderr: (chunk: string) => pipeline.onStderr(chunk), + }); + + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: result.success, + durationMs: Date.now() - started.startedAt, + }); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to execute swift test: ${message}`, + logMessage: ({ message }) => `Swift package test failed: ${message}`, + mapError: ({ message, headerEvent: hdr, emit }) => { + if (emit) { + emit(hdr); + emit(statusLine('error', `Failed to execute swift test: ${message}`)); + } + }, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/utilities/__tests__/clean.test.ts b/src/mcp/tools/utilities/__tests__/clean.test.ts index 707a67cc..02f5171b 100644 --- a/src/mcp/tools/utilities/__tests__/clean.test.ts +++ b/src/mcp/tools/utilities/__tests__/clean.test.ts @@ -6,6 +6,8 @@ import { createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('clean (unified) tool', () => { beforeEach(() => { @@ -51,17 +53,18 @@ describe('clean (unified) tool', () => { it('runs project-path flow via logic', async () => { const mock = createMockExecutor({ success: true, output: 'ok' }); - const result = await cleanLogic({ projectPath: '/p.xcodeproj', scheme: 'App' } as any, mock); - expect(result.isError).not.toBe(true); + const result = await runLogic(() => + cleanLogic({ projectPath: '/p.xcodeproj', scheme: 'App' } as any, mock), + ); + expect(result.isError).toBeFalsy(); }); it('runs workspace-path flow via logic', async () => { const mock = createMockExecutor({ success: true, output: 'ok' }); - const result = await cleanLogic( - { workspacePath: '/w.xcworkspace', scheme: 'App' } as any, - mock, + const result = await runLogic(() => + cleanLogic({ workspacePath: '/w.xcworkspace', scheme: 'App' } as any, mock), ); - expect(result.isError).not.toBe(true); + expect(result.isError).toBeFalsy(); }); it('handler validation: requires scheme when workspacePath is provided', async () => { @@ -79,13 +82,11 @@ describe('clean (unified) tool', () => { return createMockCommandResponse({ success: true, output: 'clean success' }); }; - const result = await cleanLogic( - { projectPath: '/p.xcodeproj', scheme: 'App' } as any, - mockExecutor, + const result = await runLogic(() => + cleanLogic({ projectPath: '/p.xcodeproj', scheme: 'App' } as any, mockExecutor), ); - expect(result.isError).not.toBe(true); + expect(result.isError).toBeFalsy(); - // Check that the command contains iOS platform destination const commandStr = capturedCommand.join(' '); expect(commandStr).toContain('-destination'); expect(commandStr).toContain('platform=iOS'); @@ -98,17 +99,18 @@ describe('clean (unified) tool', () => { return createMockCommandResponse({ success: true, output: 'clean success' }); }; - const result = await cleanLogic( - { - projectPath: '/p.xcodeproj', - scheme: 'App', - platform: 'macOS', - } as any, - mockExecutor, + const result = await runLogic(() => + cleanLogic( + { + projectPath: '/p.xcodeproj', + scheme: 'App', + platform: 'macOS', + } as any, + mockExecutor, + ), ); - expect(result.isError).not.toBe(true); + expect(result.isError).toBeFalsy(); - // Check that the command contains macOS platform destination const commandStr = capturedCommand.join(' '); expect(commandStr).toContain('-destination'); expect(commandStr).toContain('platform=macOS'); @@ -121,17 +123,18 @@ describe('clean (unified) tool', () => { return createMockCommandResponse({ success: true, output: 'clean success' }); }; - const result = await cleanLogic( - { - projectPath: '/p.xcodeproj', - scheme: 'App', - platform: 'iOS Simulator', - } as any, - mockExecutor, + const result = await runLogic(() => + cleanLogic( + { + projectPath: '/p.xcodeproj', + scheme: 'App', + platform: 'iOS Simulator', + } as any, + mockExecutor, + ), ); - expect(result.isError).not.toBe(true); + expect(result.isError).toBeFalsy(); - // For clean operations, iOS Simulator should be mapped to iOS platform const commandStr = capturedCommand.join(' '); expect(commandStr).toContain('-destination'); expect(commandStr).toContain('platform=iOS'); diff --git a/src/mcp/tools/utilities/clean.ts b/src/mcp/tools/utilities/clean.ts index 1d683d1e..d4465b30 100644 --- a/src/mcp/tools/utilities/clean.ts +++ b/src/mcp/tools/utilities/clean.ts @@ -1,24 +1,18 @@ -/** - * Utilities Plugin: Clean (Unified) - * - * Cleans build products for either a project or workspace using xcodebuild. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; +import path from 'node:path'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import type { ToolResponse, SharedBuildParams } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { constructDestinationString } from '../../../utils/xcode.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { scheme: z.string().optional().describe('Optional: The scheme to clean'), configuration: z @@ -66,77 +60,116 @@ const cleanSchema = z.preprocess( export type CleanParams = z.infer; -export async function cleanLogic( - params: CleanParams, - executor: CommandExecutor, -): Promise { - // Extra safety: ensure workspace path has a scheme (xcodebuild requires it) +const PLATFORM_MAP: Record = { + macOS: XcodePlatform.macOS, + iOS: XcodePlatform.iOS, + 'iOS Simulator': XcodePlatform.iOSSimulator, + watchOS: XcodePlatform.watchOS, + 'watchOS Simulator': XcodePlatform.watchOSSimulator, + tvOS: XcodePlatform.tvOS, + 'tvOS Simulator': XcodePlatform.tvOSSimulator, + visionOS: XcodePlatform.visionOS, + 'visionOS Simulator': XcodePlatform.visionOSSimulator, +}; + +const SIMULATOR_TO_DEVICE_PLATFORM: Partial> = { + [XcodePlatform.iOSSimulator]: XcodePlatform.iOS, + [XcodePlatform.watchOSSimulator]: XcodePlatform.watchOS, + [XcodePlatform.tvOSSimulator]: XcodePlatform.tvOS, + [XcodePlatform.visionOSSimulator]: XcodePlatform.visionOS, +}; + +export async function cleanLogic(params: CleanParams, executor: CommandExecutor): Promise { + const headerEvent = header('Clean'); + + const ctx = getHandlerContext(); + if (params.workspacePath && !params.scheme) { - return createErrorResponse( - 'Parameter validation failed', - 'Invalid parameters:\nscheme: scheme is required when workspacePath is provided.', - ); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', 'scheme is required when workspacePath is provided.')); + return; } - // Use provided platform or default to iOS const targetPlatform = params.platform ?? 'iOS'; - // Map human-friendly platform names to XcodePlatform enum values - // This is safer than direct key lookup and handles the space-containing simulator names - const platformMap = { - macOS: XcodePlatform.macOS, - iOS: XcodePlatform.iOS, - 'iOS Simulator': XcodePlatform.iOSSimulator, - watchOS: XcodePlatform.watchOS, - 'watchOS Simulator': XcodePlatform.watchOSSimulator, - tvOS: XcodePlatform.tvOS, - 'tvOS Simulator': XcodePlatform.tvOSSimulator, - visionOS: XcodePlatform.visionOS, - 'visionOS Simulator': XcodePlatform.visionOSSimulator, - }; - - const platformEnum = platformMap[targetPlatform]; + const platformEnum = PLATFORM_MAP[targetPlatform]; if (!platformEnum) { - return createErrorResponse( - 'Parameter validation failed', - `Invalid parameters:\nplatform: unsupported value "${targetPlatform}".`, - ); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Unsupported platform: "${targetPlatform}".`)); + return; } - const hasProjectPath = typeof params.projectPath === 'string'; - const typedParams: SharedBuildParams = { - ...(hasProjectPath - ? { projectPath: params.projectPath as string } - : { workspacePath: params.workspacePath as string }), - // scheme may be omitted for project; when omitted we do not pass -scheme - // Provide empty string to satisfy type, executeXcodeBuildCommand only emits -scheme when non-empty - scheme: params.scheme ?? '', - configuration: params.configuration ?? 'Debug', - derivedDataPath: params.derivedDataPath, - extraArgs: params.extraArgs, - }; - - // For clean operations, simulator platforms should be mapped to their device equivalents - // since clean works at the build product level, not runtime level, and build products - // are shared between device and simulator platforms - const cleanPlatformMap: Partial> = { - [XcodePlatform.iOSSimulator]: XcodePlatform.iOS, - [XcodePlatform.watchOSSimulator]: XcodePlatform.watchOS, - [XcodePlatform.tvOSSimulator]: XcodePlatform.tvOS, - [XcodePlatform.visionOSSimulator]: XcodePlatform.visionOS, - }; - - const cleanPlatform = cleanPlatformMap[platformEnum] ?? platformEnum; - - return executeXcodeBuildCommand( - typedParams, + const cleanPlatform = SIMULATOR_TO_DEVICE_PLATFORM[platformEnum] ?? platformEnum; + const scheme = params.scheme ?? ''; + const configuration = params.configuration ?? 'Debug'; + + const cleanHeaderEvent = header('Clean', [ + ...(scheme ? [{ label: 'Scheme', value: scheme }] : []), + ...(params.workspacePath ? [{ label: 'Workspace', value: params.workspacePath }] : []), + ...(params.projectPath ? [{ label: 'Project', value: params.projectPath }] : []), + { label: 'Configuration', value: configuration }, + { label: 'Platform', value: String(cleanPlatform) }, + ]); + + const command = ['xcodebuild']; + let projectDir = ''; + + if (params.workspacePath) { + const wsPath = path.isAbsolute(params.workspacePath) + ? params.workspacePath + : path.resolve(process.cwd(), params.workspacePath); + projectDir = path.dirname(wsPath); + command.push('-workspace', wsPath); + } else if (params.projectPath) { + const projPath = path.isAbsolute(params.projectPath) + ? params.projectPath + : path.resolve(process.cwd(), params.projectPath); + projectDir = path.dirname(projPath); + command.push('-project', projPath); + } + + command.push('-scheme', scheme); + command.push('-configuration', configuration); + command.push('-destination', constructDestinationString(cleanPlatform)); + + if (params.derivedDataPath) { + const ddPath = path.isAbsolute(params.derivedDataPath) + ? params.derivedDataPath + : path.resolve(process.cwd(), params.derivedDataPath); + command.push('-derivedDataPath', ddPath); + } + + if (params.extraArgs && params.extraArgs.length > 0) { + command.push(...params.extraArgs); + } + + command.push('clean'); + + return withErrorHandling( + ctx, + async () => { + const result = await executor(command, 'Clean', false, { cwd: projectDir }); + + if (!result.success) { + const combinedOutput = [result.error, result.output].filter(Boolean).join('\n').trim(); + const errorLines = combinedOutput + .split('\n') + .filter((line) => /error:/i.test(line)) + .map((line) => line.trim()); + const errorMessage = errorLines.length > 0 ? errorLines.join('; ') : 'Unknown error'; + ctx.emit(cleanHeaderEvent); + ctx.emit(statusLine('error', `Clean failed: ${errorMessage}`)); + return; + } + + ctx.emit(cleanHeaderEvent); + ctx.emit(statusLine('success', 'Clean successful')); + }, { - platform: cleanPlatform, - logPrefix: 'Clean', + header: cleanHeaderEvent, + errorMessage: ({ message }) => `Clean failed: ${message}`, + logMessage: ({ message }) => `Clean failed: ${message}`, }, - false, - 'clean', - executor, ); } diff --git a/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts b/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts index e29b1eeb..f852c769 100644 --- a/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts +++ b/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts @@ -21,6 +21,9 @@ vi.mock('../../../../utils/config-store.ts', () => ({ import { manage_workflowsLogic } from '../manage_workflows.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + + import { applyWorkflowSelectionFromManifest, getRegisteredWorkflows, @@ -40,16 +43,15 @@ describe('manage_workflows tool', () => { }); const executor = createMockExecutor({ success: true, output: '' }); - const result = await manage_workflowsLogic( - { workflowNames: ['device'], enable: true }, - executor, + const result = await runLogic(() => + manage_workflowsLogic({ workflowNames: ['device'], enable: true }, executor), ); expect(vi.mocked(applyWorkflowSelectionFromManifest)).toHaveBeenCalledWith( ['simulator', 'device'], expect.objectContaining({ runtime: 'mcp' }), ); - expect(result.content[0].text).toBe('Workflows enabled: simulator, device'); + expect(result.isError).toBeUndefined(); }); it('removes requested workflows when enable is false', async () => { @@ -60,16 +62,15 @@ describe('manage_workflows tool', () => { }); const executor = createMockExecutor({ success: true, output: '' }); - const result = await manage_workflowsLogic( - { workflowNames: ['device'], enable: false }, - executor, + const result = await runLogic(() => + manage_workflowsLogic({ workflowNames: ['device'], enable: false }, executor), ); expect(vi.mocked(applyWorkflowSelectionFromManifest)).toHaveBeenCalledWith( ['simulator'], expect.objectContaining({ runtime: 'mcp' }), ); - expect(result.content[0].text).toBe('Workflows enabled: simulator'); + expect(result.isError).toBeUndefined(); }); it('accepts workflowName as an array', async () => { @@ -80,7 +81,9 @@ describe('manage_workflows tool', () => { }); const executor = createMockExecutor({ success: true, output: '' }); - await manage_workflowsLogic({ workflowNames: ['device', 'logging'], enable: true }, executor); + await runLogic(() => + manage_workflowsLogic({ workflowNames: ['device', 'logging'], enable: true }, executor), + ); expect(vi.mocked(applyWorkflowSelectionFromManifest)).toHaveBeenCalledWith( ['simulator', 'device', 'logging'], diff --git a/src/mcp/tools/workflow-discovery/manage_workflows.ts b/src/mcp/tools/workflow-discovery/manage_workflows.ts index fc082c26..1544258d 100644 --- a/src/mcp/tools/workflow-discovery/manage_workflows.ts +++ b/src/mcp/tools/workflow-discovery/manage_workflows.ts @@ -1,14 +1,13 @@ import * as z from 'zod'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor, type CommandExecutor } from '../../../utils/execution/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import { applyWorkflowSelectionFromManifest, getRegisteredWorkflows, getMcpPredicateContext, } from '../../../utils/tool-registry.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; const baseSchemaObject = z.object({ workflowNames: z.array(z.string()).describe('Workflow directory name(s).'), @@ -22,7 +21,8 @@ export type ManageWorkflowsParams = z.infer; export async function manage_workflowsLogic( params: ManageWorkflowsParams, _neverExecutor: CommandExecutor, -): Promise { +): Promise { + const ctx = getHandlerContext(); const workflowNames = params.workflowNames; const currentWorkflows = getRegisteredWorkflows(); const requestedSet = new Set( @@ -35,11 +35,14 @@ export async function manage_workflowsLogic( nextWorkflows = [...new Set([...currentWorkflows, ...workflowNames])]; } - const ctx = getMcpPredicateContext(); + const predicateContext = getMcpPredicateContext(); + const registryState = await applyWorkflowSelectionFromManifest(nextWorkflows, predicateContext); - const registryState = await applyWorkflowSelectionFromManifest(nextWorkflows, ctx); - - return createTextResponse(`Workflows enabled: ${registryState.enabledWorkflows.join(', ')}`); + ctx.emit(header('Manage Workflows')); + ctx.emit(section('Enabled Workflows', registryState.enabledWorkflows)); + ctx.emit( + statusLine('success', `Workflows enabled: ${registryState.enabledWorkflows.join(', ')}`), + ); } export const schema = baseSchemaObject.shape; diff --git a/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts b/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts index d825710c..95e389a8 100644 --- a/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts +++ b/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts @@ -34,6 +34,7 @@ import { buildXcodeToolsBridgeStatus, getMcpBridgeAvailability, } from '../../../../integrations/xcode-tools-bridge/core.ts'; +import { allText } from '../../../../test-utils/test-helpers.ts'; describe('xcode-ide bridge tools (standalone fallback)', () => { beforeEach(async () => { @@ -80,40 +81,43 @@ describe('xcode-ide bridge tools (standalone fallback)', () => { }); it('status handler returns bridge status without MCP server instance', async () => { - const result = await statusHandler(); - const payload = JSON.parse(result.content[0].text as string); - expect(payload.bridgeAvailable).toBe(true); + const result = await statusHandler({}); + const text = allText(result); + expect(text).toContain('Bridge Status'); + expect(text).toContain('"bridgeAvailable": true'); expect(buildXcodeToolsBridgeStatus).toHaveBeenCalledOnce(); }); it('sync handler uses direct bridge client when MCP server is not initialized', async () => { - const result = await syncHandler(); - const payload = JSON.parse(result.content[0].text as string); - expect(payload.sync.total).toBe(2); + const result = await syncHandler({}); + const text = allText(result); + expect(text).toContain('Bridge Sync'); + expect(text).toContain('"total": 2'); expect(clientMocks.connectOnce).toHaveBeenCalledOnce(); expect(clientMocks.listTools).toHaveBeenCalledOnce(); expect(clientMocks.disconnect).toHaveBeenCalledOnce(); }); it('disconnect handler succeeds without MCP server instance', async () => { - const result = await disconnectHandler(); - const payload = JSON.parse(result.content[0].text as string); - expect(payload.connected).toBe(false); + const result = await disconnectHandler({}); + const text = allText(result); + expect(text).toContain('Bridge Disconnect'); + expect(text).toContain('"connected": false'); expect(clientMocks.disconnect).toHaveBeenCalledOnce(); }); it('list handler returns bridge tools without MCP server instance', async () => { const result = await listHandler({ refresh: true }); - const payload = JSON.parse(result.content[0].text as string); - expect(payload.toolCount).toBe(2); - expect(payload.tools).toHaveLength(2); + const text = allText(result); + expect(text).toContain('Xcode IDE List Tools'); + expect(text).toContain('"toolCount": 2'); expect(clientMocks.listTools).toHaveBeenCalledOnce(); expect(clientMocks.disconnect).toHaveBeenCalledOnce(); }); it('call handler forwards remote tool calls without MCP server instance', async () => { const result = await callHandler({ remoteTool: 'toolA', arguments: { foo: 'bar' } }); - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); expect(clientMocks.callTool).toHaveBeenCalledWith('toolA', { foo: 'bar' }, {}); expect(clientMocks.disconnect).toHaveBeenCalledOnce(); }); diff --git a/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts index 5a1b9ff8..9ef7cb7f 100644 --- a/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts +++ b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts @@ -1,16 +1,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { existsSync } from 'fs'; -import { join } from 'path'; import { sessionStore } from '../../../../utils/session-store.ts'; import { createCommandMatchingMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, syncXcodeDefaultsLogic } from '../sync_xcode_defaults.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; -// Path to the example project (used as test fixture) -const EXAMPLE_PROJECT_PATH = join(process.cwd(), 'example_projects/iOS/MCPTest.xcodeproj'); -const EXAMPLE_XCUSERSTATE = join( - EXAMPLE_PROJECT_PATH, - 'project.xcworkspace/xcuserdata/johndoe.xcuserdatad/UserInterfaceState.xcuserstate', -); describe('sync_xcode_defaults tool', () => { beforeEach(() => { @@ -31,10 +24,12 @@ describe('sync_xcode_defaults tool', () => { find: { output: '' }, }); - const result = await syncXcodeDefaultsLogic({}, { executor, cwd: '/test/project' }); + const result = await runLogic(() => + syncXcodeDefaultsLogic({}, { executor, cwd: '/test/project' }), + ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to read Xcode IDE state'); + expect(allText(result)).toContain('Failed to read Xcode IDE state'); }); it('returns error when xcuserstate file not found', async () => { @@ -44,129 +39,12 @@ describe('sync_xcode_defaults tool', () => { stat: { success: false, error: 'No such file' }, }); - const result = await syncXcodeDefaultsLogic({}, { executor, cwd: '/test/project' }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to read Xcode IDE state'); - }); - }); - - describe('syncXcodeDefaultsLogic integration', () => { - // These tests use the actual example project fixture - - it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))( - 'syncs scheme and simulator from example project', - async () => { - const simctlOutput = JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.xrOS-2-0': [ - { udid: 'B38FE93D-578B-454B-BE9A-C6FA0CE5F096', name: 'Apple Vision Pro' }, - ], - }, - }); - - const executor = createCommandMatchingMockExecutor({ - whoami: { output: 'johndoe\n' }, - find: { output: `${EXAMPLE_PROJECT_PATH}\n` }, - stat: { output: '1704067200\n' }, - 'xcrun simctl': { output: simctlOutput }, - xcodebuild: { output: ' PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MCPTest\n' }, - }); - - const result = await syncXcodeDefaultsLogic( - {}, - { executor, cwd: join(process.cwd(), 'example_projects/iOS') }, - ); - - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Synced session defaults from Xcode IDE'); - expect(result.content[0].text).toContain('Scheme: MCPTest'); - expect(result.content[0].text).toContain( - 'Simulator ID: B38FE93D-578B-454B-BE9A-C6FA0CE5F096', - ); - expect(result.content[0].text).toContain('Simulator Name: Apple Vision Pro'); - expect(result.content[0].text).toContain('Bundle ID: io.sentry.MCPTest'); - - const defaults = sessionStore.getAll(); - expect(defaults.scheme).toBe('MCPTest'); - expect(defaults.simulatorId).toBe('B38FE93D-578B-454B-BE9A-C6FA0CE5F096'); - expect(defaults.simulatorName).toBe('Apple Vision Pro'); - expect(defaults.bundleId).toBe('io.sentry.MCPTest'); - }, - ); - - it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))('syncs using configured projectPath', async () => { - const simctlOutput = JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.xrOS-2-0': [ - { udid: 'B38FE93D-578B-454B-BE9A-C6FA0CE5F096', name: 'Apple Vision Pro' }, - ], - }, - }); - - const executor = createCommandMatchingMockExecutor({ - whoami: { output: 'johndoe\n' }, - 'test -f': { success: true }, - 'xcrun simctl': { output: simctlOutput }, - xcodebuild: { output: ' PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MCPTest\n' }, - }); - - const result = await syncXcodeDefaultsLogic( - {}, - { - executor, - cwd: '/some/other/path', - projectPath: EXAMPLE_PROJECT_PATH, - }, + const result = await runLogic(() => + syncXcodeDefaultsLogic({}, { executor, cwd: '/test/project' }), ); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Scheme: MCPTest'); - - const defaults = sessionStore.getAll(); - expect(defaults.scheme).toBe('MCPTest'); - expect(defaults.simulatorId).toBe('B38FE93D-578B-454B-BE9A-C6FA0CE5F096'); - expect(defaults.bundleId).toBe('io.sentry.MCPTest'); - }); - - it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))('updates existing session defaults', async () => { - // Set some existing defaults - sessionStore.setDefaults({ - scheme: 'OldScheme', - simulatorId: 'OLD-SIM-UUID', - projectPath: '/some/project.xcodeproj', - }); - - const simctlOutput = JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.xrOS-2-0': [ - { udid: 'B38FE93D-578B-454B-BE9A-C6FA0CE5F096', name: 'Apple Vision Pro' }, - ], - }, - }); - - const executor = createCommandMatchingMockExecutor({ - whoami: { output: 'johndoe\n' }, - find: { output: `${EXAMPLE_PROJECT_PATH}\n` }, - stat: { output: '1704067200\n' }, - 'xcrun simctl': { output: simctlOutput }, - xcodebuild: { output: ' PRODUCT_BUNDLE_IDENTIFIER = io.sentry.MCPTest\n' }, - }); - - const result = await syncXcodeDefaultsLogic( - {}, - { executor, cwd: join(process.cwd(), 'example_projects/iOS') }, - ); - - expect(result.isError).toBe(false); - - const defaults = sessionStore.getAll(); - expect(defaults.scheme).toBe('MCPTest'); - expect(defaults.simulatorId).toBe('B38FE93D-578B-454B-BE9A-C6FA0CE5F096'); - expect(defaults.simulatorName).toBe('Apple Vision Pro'); - expect(defaults.bundleId).toBe('io.sentry.MCPTest'); - // Original projectPath should be preserved - expect(defaults.projectPath).toBe('/some/project.xcodeproj'); + expect(result.isError).toBe(true); + expect(allText(result)).toContain('Failed to read Xcode IDE state'); }); }); }); diff --git a/src/mcp/tools/xcode-ide/shared.ts b/src/mcp/tools/xcode-ide/shared.ts index 15de889d..2129d174 100644 --- a/src/mcp/tools/xcode-ide/shared.ts +++ b/src/mcp/tools/xcode-ide/shared.ts @@ -1,15 +1,35 @@ -import type { ToolResponse } from '../../../types/common.ts'; -import type { XcodeToolsBridgeToolHandler } from '../../../integrations/xcode-tools-bridge/index.ts'; +import type { + BridgeToolResult, + XcodeToolsBridgeToolHandler, +} from '../../../integrations/xcode-tools-bridge/index.ts'; import { getServer } from '../../../server/server-state.ts'; import { getXcodeToolsBridgeToolHandler } from '../../../integrations/xcode-tools-bridge/index.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; export async function withBridgeToolHandler( - callback: (bridge: XcodeToolsBridgeToolHandler) => Promise, -): Promise { + operation: string, + callback: (bridge: XcodeToolsBridgeToolHandler) => Promise, +): Promise { + const ctx = getHandlerContext(); const bridge = getXcodeToolsBridgeToolHandler(getServer()); if (!bridge) { - return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); + ctx.emit(header(operation)); + ctx.emit(statusLine('error', 'Unable to initialize xcode tools bridge')); + return; + } + + const result = await callback(bridge); + + for (const event of result.events) { + ctx.emit(event); + } + + for (const img of result.images ?? []) { + ctx.attach(img); + } + + if (result.nextStepParams) { + ctx.nextStepParams = result.nextStepParams; } - return callback(bridge); } diff --git a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts index 4c9b91fb..e6e82040 100644 --- a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts +++ b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts @@ -1,21 +1,15 @@ -/** - * Sync Xcode Defaults Tool - * - * Reads Xcode's IDE state (active scheme and run destination) and updates - * session defaults to match. This allows the agent to re-sync if the user - * changes their selection in Xcode mid-session. - * - * Only visible when running under Xcode's coding agent. - */ - -import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + createTypedToolWithContext, + getHandlerContext, +} from '../../../utils/typed-tool-factory.ts'; import { sessionStore } from '../../../utils/session-store.ts'; import { readXcodeIdeState } from '../../../utils/xcode-state-reader.ts'; import { lookupBundleId } from '../../../utils/xcode-state-watcher.ts'; import * as z from 'zod'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; +import { formatProfileAnnotation } from '../session-management/session-format-helpers.ts'; const schemaObj = z.object({}); @@ -31,7 +25,10 @@ interface SyncXcodeDefaultsContext { export async function syncXcodeDefaultsLogic( _params: Params, ctx: SyncXcodeDefaultsContext, -): Promise { +): Promise { + const handlerContext = getHandlerContext(); + const headerEvent = header('Sync Xcode Defaults'); + const xcodeState = await readXcodeIdeState({ executor: ctx.executor, cwd: ctx.cwd, @@ -40,36 +37,25 @@ export async function syncXcodeDefaultsLogic( }); if (xcodeState.error) { - return { - content: [ - { - type: 'text', - text: `Failed to read Xcode IDE state: ${xcodeState.error}`, - }, - ], - isError: true, - }; + handlerContext.emit(headerEvent); + handlerContext.emit(statusLine('error', `Failed to read Xcode IDE state: ${xcodeState.error}`)); + return; } const synced: Record = {}; - const notices: string[] = []; if (xcodeState.scheme) { synced.scheme = xcodeState.scheme; - notices.push(`Scheme: ${xcodeState.scheme}`); } if (xcodeState.simulatorId) { synced.simulatorId = xcodeState.simulatorId; - notices.push(`Simulator ID: ${xcodeState.simulatorId}`); } if (xcodeState.simulatorName) { synced.simulatorName = xcodeState.simulatorName; - notices.push(`Simulator Name: ${xcodeState.simulatorName}`); } - // Look up bundle ID if we have a scheme if (xcodeState.scheme) { const bundleId = await lookupBundleId( ctx.executor, @@ -79,33 +65,28 @@ export async function syncXcodeDefaultsLogic( ); if (bundleId) { synced.bundleId = bundleId; - notices.push(`Bundle ID: ${bundleId}`); } } if (Object.keys(synced).length === 0) { - return { - content: [ - { - type: 'text', - text: 'No scheme or simulator selection detected in Xcode IDE state.', - }, - ], - isError: false, - }; + handlerContext.emit(headerEvent); + handlerContext.emit( + statusLine('info', 'No scheme or simulator selection detected in Xcode IDE state.'), + ); + return; } sessionStore.setDefaults(synced); - return { - content: [ - { - type: 'text', - text: `Synced session defaults from Xcode IDE:\n- ${notices.join('\n- ')}`, - }, - ], - isError: false, - }; + const activeProfile = sessionStore.getActiveProfile(); + const profileAnnotation = formatProfileAnnotation(activeProfile); + const items = Object.entries(synced).map(([k, v]) => ({ label: k, value: v })); + + handlerContext.emit(headerEvent); + handlerContext.emit( + statusLine('success', `Synced session defaults from Xcode IDE ${profileAnnotation}`), + ); + handlerContext.emit(detailTree(items)); } export const schema = schemaObj.shape; diff --git a/src/mcp/tools/xcode-ide/xcode_ide_call_tool.ts b/src/mcp/tools/xcode-ide/xcode_ide_call_tool.ts index f15e2694..2abfdf8d 100644 --- a/src/mcp/tools/xcode-ide/xcode_ide_call_tool.ts +++ b/src/mcp/tools/xcode-ide/xcode_ide_call_tool.ts @@ -1,6 +1,5 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { withBridgeToolHandler } from './shared.ts'; const schemaObject = z.object({ @@ -21,8 +20,8 @@ const schemaObject = z.object({ type Params = z.infer; -export async function xcodeIdeCallToolLogic(params: Params): Promise { - return withBridgeToolHandler((bridge) => +export async function xcodeIdeCallToolLogic(params: Params): Promise { + await withBridgeToolHandler('Xcode IDE Call Tool', (bridge) => bridge.callToolTool({ remoteTool: params.remoteTool, arguments: params.arguments ?? {}, @@ -33,16 +32,8 @@ export async function xcodeIdeCallToolLogic(params: Params): Promise = {}): Promise => { - const parsed = schemaObject.safeParse(args); - if (!parsed.success) { - const details = parsed.error.issues - .map((issue) => { - const path = issue.path.length > 0 ? issue.path.join('.') : 'root'; - return `${path}: ${issue.message}`; - }) - .join('\n'); - return createErrorResponse('Parameter validation failed', details); - } - return xcodeIdeCallToolLogic(parsed.data); -}; +export const handler = createTypedToolWithContext( + schemaObject, + (params: Params) => xcodeIdeCallToolLogic(params), + () => undefined, +); diff --git a/src/mcp/tools/xcode-ide/xcode_ide_list_tools.ts b/src/mcp/tools/xcode-ide/xcode_ide_list_tools.ts index 152d4715..9ef5cfe0 100644 --- a/src/mcp/tools/xcode-ide/xcode_ide_list_tools.ts +++ b/src/mcp/tools/xcode-ide/xcode_ide_list_tools.ts @@ -1,6 +1,5 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { withBridgeToolHandler } from './shared.ts'; const schemaObject = z.object({ @@ -12,22 +11,16 @@ const schemaObject = z.object({ type Params = z.infer; -export async function xcodeIdeListToolsLogic(params: Params): Promise { - return withBridgeToolHandler(async (bridge) => bridge.listToolsTool({ refresh: params.refresh })); +export async function xcodeIdeListToolsLogic(params: Params): Promise { + await withBridgeToolHandler('Xcode IDE List Tools', async (bridge) => + bridge.listToolsTool({ refresh: params.refresh }), + ); } export const schema = schemaObject.shape; -export const handler = async (args: Record = {}): Promise => { - const parsed = schemaObject.safeParse(args); - if (!parsed.success) { - const details = parsed.error.issues - .map((issue) => { - const path = issue.path.length > 0 ? issue.path.join('.') : 'root'; - return `${path}: ${issue.message}`; - }) - .join('\n'); - return createErrorResponse('Parameter validation failed', details); - } - return xcodeIdeListToolsLogic(parsed.data); -}; +export const handler = createTypedToolWithContext( + schemaObject, + (params: Params) => xcodeIdeListToolsLogic(params), + () => undefined, +); diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts index d81f1750..7865f2c3 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts @@ -1,8 +1,17 @@ -import type { ToolResponse } from '../../../types/common.ts'; +import * as z from 'zod'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { withBridgeToolHandler } from './shared.ts'; -export const schema = {}; +const schemaObject = z.object({}); -export const handler = async (): Promise => { - return withBridgeToolHandler(async (bridge) => bridge.disconnectTool()); -}; +export async function xcodeToolsBridgeDisconnectLogic(): Promise { + await withBridgeToolHandler('Bridge Disconnect', async (bridge) => bridge.disconnectTool()); +} + +export const schema = schemaObject.shape; + +export const handler = createTypedToolWithContext( + schemaObject, + () => xcodeToolsBridgeDisconnectLogic(), + () => undefined, +); diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts index f3dae68e..978cbcbd 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts @@ -1,8 +1,17 @@ -import type { ToolResponse } from '../../../types/common.ts'; +import * as z from 'zod'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { withBridgeToolHandler } from './shared.ts'; -export const schema = {}; +const schemaObject = z.object({}); -export const handler = async (): Promise => { - return withBridgeToolHandler(async (bridge) => bridge.statusTool()); -}; +export async function xcodeToolsBridgeStatusLogic(): Promise { + await withBridgeToolHandler('Bridge Status', async (bridge) => bridge.statusTool()); +} + +export const schema = schemaObject.shape; + +export const handler = createTypedToolWithContext( + schemaObject, + () => xcodeToolsBridgeStatusLogic(), + () => undefined, +); diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts index af609325..1713499c 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts @@ -1,8 +1,17 @@ -import type { ToolResponse } from '../../../types/common.ts'; +import * as z from 'zod'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { withBridgeToolHandler } from './shared.ts'; -export const schema = {}; +const schemaObject = z.object({}); -export const handler = async (): Promise => { - return withBridgeToolHandler(async (bridge) => bridge.syncTool()); -}; +export async function xcodeToolsBridgeSyncLogic(): Promise { + await withBridgeToolHandler('Bridge Sync', async (bridge) => bridge.syncTool()); +} + +export const schema = schemaObject.shape; + +export const handler = createTypedToolWithContext( + schemaObject, + () => xcodeToolsBridgeSyncLogic(), + () => undefined, +); diff --git a/src/utils/debugger/backends/__tests__/dap-backend.test.ts b/src/utils/debugger/backends/__tests__/dap-backend.test.ts index 8aa01bf5..484e7727 100644 --- a/src/utils/debugger/backends/__tests__/dap-backend.test.ts +++ b/src/utils/debugger/backends/__tests__/dap-backend.test.ts @@ -101,6 +101,17 @@ function createDefaultHandlers() { output: 'evaluated', }, }), + pause: () => ({ + body: {}, + events: [ + { + seq: 2000, + type: 'event', + event: 'stopped', + body: { reason: 'pause', threadId: 1 }, + }, + ], + }), setBreakpoints: (request: DapRequest) => { const args = request.arguments as { breakpoints: Array<{ line: number }> }; const breakpoints = (args?.breakpoints ?? []).map((bp, index) => ({ @@ -168,4 +179,22 @@ describe('DapBackend', () => { await backend.detach(); await backend.dispose(); }); + + it('maps process interrupt to a DAP pause request', async () => { + const handlers = createDefaultHandlers(); + const spawner = createDapSpawner(handlers); + const executor = createMockExecutor({ success: true, output: '/usr/bin/lldb-dap' }); + + const backend = await createDapBackend({ executor, spawner, requestTimeoutMs: 1_000 }); + await backend.attach({ pid: 4242, simulatorId: 'sim-1' }); + + const output = await backend.runCommand('process interrupt'); + expect(output).toBe('Process interrupted.'); + + const stack = await backend.getStack(); + expect(stack).toContain('frame #0: main at /tmp/main.swift:42'); + + await backend.detach(); + await backend.dispose(); + }); }); diff --git a/src/utils/debugger/backends/dap-backend.ts b/src/utils/debugger/backends/dap-backend.ts index ae1566e8..57716108 100644 --- a/src/utils/debugger/backends/dap-backend.ts +++ b/src/utils/debugger/backends/dap-backend.ts @@ -135,6 +135,14 @@ class DapBackend implements DebuggerBackend { async runCommand(command: string, opts?: { timeoutMs?: number }): Promise { this.ensureAttached(); + const normalizedCommand = command.trim().toLowerCase(); + if (normalizedCommand === 'process interrupt') { + return this.enqueue(async () => { + await this.pauseExecution(opts?.timeoutMs); + return 'Process interrupted.'; + }); + } + try { const body = await this.request< { expression: string; context: string }, @@ -407,6 +415,45 @@ class DapBackend implements DebuggerBackend { }); } + private async pauseExecution(timeoutMs?: number): Promise { + const thread = await this.resolveThread(); + const waitForStop = this.waitForEvent('stopped', timeoutMs); + + await this.request<{ threadId: number }, Record>( + 'pause', + { threadId: thread.id }, + { timeoutMs }, + ); + + await waitForStop; + this.executionState = { status: 'stopped', threadId: thread.id }; + } + + private waitForEvent(eventName: string, timeoutMs?: number): Promise { + const transport = this.transport; + if (!transport) { + throw new Error('DAP transport not initialized.'); + } + + return new Promise((resolve, reject) => { + const effectiveTimeoutMs = timeoutMs ?? this.requestTimeoutMs; + const timer = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for DAP event: ${eventName}`)); + }, effectiveTimeoutMs); + + const unsubscribe = transport.onEvent((event) => { + if (event.event !== eventName) { + return; + } + + clearTimeout(timer); + unsubscribe(); + resolve(event); + }); + }); + } + private async resolveThread(threadIndex?: number): Promise<{ id: number; name?: string }> { const body = await this.request('threads'); const threads = body.threads ?? []; @@ -588,11 +635,10 @@ export async function createDapBackend(opts?: { const executor = opts?.executor ?? getDefaultCommandExecutor(); const spawner = opts?.spawner ?? getDefaultInteractiveSpawner(); const requestTimeoutMs = opts?.requestTimeoutMs ?? config.dapRequestTimeoutMs; - const backend = new DapBackend({ + return new DapBackend({ executor, spawner, requestTimeoutMs, logEvents: config.dapLogEvents, }); - return backend; } diff --git a/src/utils/debugger/dap/transport.ts b/src/utils/debugger/dap/transport.ts index 636668cf..c5735ec7 100644 --- a/src/utils/debugger/dap/transport.ts +++ b/src/utils/debugger/dap/transport.ts @@ -173,7 +173,9 @@ export class DapTransport { clearTimeout(pending.timeout); if (!message.success) { - const detail = message.message ?? 'DAP request failed'; + const bodyError = (message.body as { error?: { format?: string } } | undefined)?.error + ?.format; + const detail = message.message ?? bodyError ?? 'DAP request failed'; pending.reject(new Error(`${pending.command} failed: ${detail}`)); return; } diff --git a/src/utils/debugger/ui-automation-guard.ts b/src/utils/debugger/ui-automation-guard.ts index 0c005a12..05434d4f 100644 --- a/src/utils/debugger/ui-automation-guard.ts +++ b/src/utils/debugger/ui-automation-guard.ts @@ -1,12 +1,9 @@ -import type { ToolResponse } from '../../types/common.ts'; -import { createErrorResponse } from '../responses/index.ts'; import { log } from '../logging/index.ts'; import { getUiDebuggerGuardMode } from '../environment.ts'; import type { DebugExecutionState } from './types.ts'; import type { DebuggerManager } from './debugger-manager.ts'; -type GuardResult = { - blockedResponse?: ToolResponse; +export type GuardResult = { blockedMessage?: string; warningText?: string; }; @@ -52,10 +49,6 @@ export async function guardUiAutomationAgainstStoppedDebugger(opts: { } return { - blockedResponse: createErrorResponse( - 'UI automation blocked: app is paused in debugger', - details, - ), blockedMessage: `UI automation blocked: app is paused in debugger\n${details}`, }; }