From 92db053a216ba1e3ca3cda6e937880f93fa4373b Mon Sep 17 00:00:00 2001 From: Ranga Reddy Nukala Date: Mon, 23 Feb 2026 16:52:10 -0800 Subject: [PATCH 01/14] Add tool manifests and coverage workflow for code coverage tools --- .gitignore | 2 +- manifests/tools/get_coverage_report.yaml | 13 +++++++++++++ manifests/tools/get_file_coverage.yaml | 13 +++++++++++++ manifests/workflows/coverage.yaml | 6 ++++++ 4 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 manifests/tools/get_coverage_report.yaml create mode 100644 manifests/tools/get_file_coverage.yaml create mode 100644 manifests/workflows/coverage.yaml diff --git a/.gitignore b/.gitignore index 1dd9bbfb..b1bb9f21 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,7 @@ logs/ *.log # Test coverage -coverage/ +/coverage/ # macOS specific .DS_Store diff --git a/manifests/tools/get_coverage_report.yaml b/manifests/tools/get_coverage_report.yaml new file mode 100644 index 00000000..e126ef07 --- /dev/null +++ b/manifests/tools/get_coverage_report.yaml @@ -0,0 +1,13 @@ +id: get_coverage_report +module: mcp/tools/coverage/get_coverage_report +names: + mcp: get_coverage_report + cli: get-coverage-report +description: Show per-target code coverage from an xcresult bundle. +annotations: + title: Get Coverage Report + readOnlyHint: true +nextSteps: + - label: View file-level coverage + toolId: get_file_coverage + priority: 1 diff --git a/manifests/tools/get_file_coverage.yaml b/manifests/tools/get_file_coverage.yaml new file mode 100644 index 00000000..d6eb1be7 --- /dev/null +++ b/manifests/tools/get_file_coverage.yaml @@ -0,0 +1,13 @@ +id: get_file_coverage +module: mcp/tools/coverage/get_file_coverage +names: + mcp: get_file_coverage + cli: get-file-coverage +description: Show function-level coverage and uncovered line ranges for a specific file. +annotations: + title: Get File Coverage + readOnlyHint: true +nextSteps: + - label: View overall coverage + toolId: get_coverage_report + priority: 1 diff --git a/manifests/workflows/coverage.yaml b/manifests/workflows/coverage.yaml new file mode 100644 index 00000000..f3ee6116 --- /dev/null +++ b/manifests/workflows/coverage.yaml @@ -0,0 +1,6 @@ +id: coverage +title: Code Coverage +description: View code coverage data from xcresult bundles produced by test runs. +tools: + - get_coverage_report + - get_file_coverage From f2e0aeb54496db94e203516192ea4ef1bf895071 Mon Sep 17 00:00:00 2001 From: Ranga Reddy Nukala Date: Mon, 23 Feb 2026 16:52:30 -0800 Subject: [PATCH 02/14] Add get_coverage_report tool for per-target coverage from xcresult bundles --- src/mcp/tools/coverage/get_coverage_report.ts | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/mcp/tools/coverage/get_coverage_report.ts diff --git a/src/mcp/tools/coverage/get_coverage_report.ts b/src/mcp/tools/coverage/get_coverage_report.ts new file mode 100644 index 00000000..ea76e53e --- /dev/null +++ b/src/mcp/tools/coverage/get_coverage_report.ts @@ -0,0 +1,184 @@ +/** + * Coverage Tool: Get Coverage Report + * + * Shows overall per-target code coverage from an xcresult bundle. + * Uses `xcrun xccov view --report` to extract coverage data. + */ + +import * as z from 'zod'; +import type { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +const getCoverageReportSchema = z.object({ + xcresultPath: z.string().describe('Path to the .xcresult bundle'), + target: z.string().optional().describe('Filter results to a specific target name'), + showFiles: z + .boolean() + .optional() + .default(false) + .describe('When true, include per-file coverage breakdown under each target'), +}); + +type GetCoverageReportParams = z.infer; + +interface CoverageFile { + coveredLines: number; + executableLines: number; + lineCoverage: number; + name: string; + path: string; +} + +interface CoverageTarget { + coveredLines: number; + executableLines: number; + lineCoverage: number; + name: string; + files?: CoverageFile[]; +} + +export async function get_coverage_reportLogic( + params: GetCoverageReportParams, + executor: CommandExecutor, +): Promise { + const { xcresultPath, target, showFiles } = params; + + log('info', `Getting coverage report from: ${xcresultPath}`); + + const cmd = ['xcrun', 'xccov', 'view', '--report']; + if (!showFiles) { + cmd.push('--only-targets'); + } + cmd.push('--json', xcresultPath); + + const result = await executor(cmd, 'Get Coverage Report', false, undefined); + + 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, + }; + } + + 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, + }; + } + + // Validate structure: expect an array of target objects or { targets: [...] } + let targets: CoverageTarget[] = []; + if (Array.isArray(data)) { + targets = data as CoverageTarget[]; + } else if ( + typeof data === 'object' && + data !== null && + 'targets' in data && + Array.isArray((data as { targets: unknown }).targets) + ) { + targets = (data as { targets: CoverageTarget[] }).targets; + } else { + return { + content: [ + { + type: 'text', + text: `Unexpected coverage data format.\n\nRaw output:\n${result.output}`, + }, + ], + isError: true, + }; + } + + // 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, + }; + } + } + + 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, + }; + } + + // 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) { + totalCovered += t.coveredLines; + 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); + + for (const t of targets) { + const pct = (t.lineCoverage * 100).toFixed(1); + text += ` ${t.name}: ${pct}% (${t.coveredLines}/${t.executableLines} lines)\n`; + + 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`; + } + text += '\n'; + } + } + + return { + content: [{ type: 'text', text }], + nextStepParams: { + get_file_coverage: { xcresultPath }, + }, + }; +} + +export const schema = getCoverageReportSchema.shape; + +export const handler = createTypedTool( + getCoverageReportSchema, + get_coverage_reportLogic, + getDefaultCommandExecutor, +); From 5381d5ab9a0815833311136e59d58aa3edf7f4ee Mon Sep 17 00:00:00 2001 From: Ranga Reddy Nukala Date: Mon, 23 Feb 2026 16:52:46 -0800 Subject: [PATCH 03/14] Add get_file_coverage tool for function-level coverage and uncovered line ranges --- src/mcp/tools/coverage/get_file_coverage.ts | 261 ++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 src/mcp/tools/coverage/get_file_coverage.ts diff --git a/src/mcp/tools/coverage/get_file_coverage.ts b/src/mcp/tools/coverage/get_file_coverage.ts new file mode 100644 index 00000000..dea7c88b --- /dev/null +++ b/src/mcp/tools/coverage/get_file_coverage.ts @@ -0,0 +1,261 @@ +/** + * Coverage Tool: Get File Coverage + * + * Shows function-level coverage and optionally uncovered line ranges + * for a specific file from an xcresult bundle. + */ + +import * as z from 'zod'; +import type { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +const getFileCoverageSchema = z.object({ + xcresultPath: z.string().describe('Path to the .xcresult bundle'), + file: z.string().describe('Source file name or path to inspect'), + showLines: z + .boolean() + .optional() + .default(false) + .describe('When true, include uncovered line ranges from the archive'), +}); + +type GetFileCoverageParams = z.infer; + +interface CoverageFunction { + coveredLines: number; + executableLines: number; + executionCount: number; + lineCoverage: number; + lineNumber: number; + name: string; +} + +interface RawFileEntry { + file?: string; + path?: string; + name?: string; + coveredLines?: number; + executableLines?: number; + lineCoverage?: number; + functions?: CoverageFunction[]; +} + +interface FileFunctionCoverage { + filePath: string; + coveredLines: number; + executableLines: number; + lineCoverage: number; + functions: CoverageFunction[]; +} + +function normalizeFileEntry(raw: RawFileEntry): FileFunctionCoverage { + const functions = raw.functions ?? []; + const coveredLines = + raw.coveredLines ?? functions.reduce((sum, fn) => sum + fn.coveredLines, 0); + const executableLines = + raw.executableLines ?? functions.reduce((sum, fn) => sum + fn.executableLines, 0); + const lineCoverage = + raw.lineCoverage ?? (executableLines > 0 ? coveredLines / executableLines : 0); + const filePath = raw.file ?? raw.path ?? raw.name ?? 'unknown'; + return { filePath, coveredLines, executableLines, lineCoverage, functions }; +} + +export async function get_file_coverageLogic( + params: GetFileCoverageParams, + executor: CommandExecutor, +): Promise { + const { xcresultPath, file, showLines } = params; + + log('info', `Getting file coverage for "${file}" from: ${xcresultPath}`); + + // Get function-level coverage + const funcResult = await 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, + }; + } + + 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, + }; + } + + // The output can be: + // - An array of { file, functions } objects (xccov flat format) + // - { targets: [{ files: [...] }] } (nested format) + let fileEntries: FileFunctionCoverage[] = []; + + if (Array.isArray(data)) { + fileEntries = (data as RawFileEntry[]).map(normalizeFileEntry); + } else if (typeof data === 'object' && data !== null && 'targets' in data) { + const targets = (data as { targets: { files?: RawFileEntry[] }[] }).targets; + for (const t of targets) { + if (t.files) { + fileEntries.push(...t.files.map(normalizeFileEntry)); + } + } + } + + 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, + }; + } + + // Build human-readable output + let text = ''; + + 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'; + + 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`; + } + + // 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`; + } + } + } + + text += '\n'; + } + + // Optionally get line-by-line coverage from the archive + if (showLines) { + const filePath = fileEntries[0].filePath !== 'unknown' ? fileEntries[0].filePath : file; + const archiveResult = await 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:\n'; + for (const range of uncoveredRanges) { + if (range.start === range.end) { + text += ` L${range.start}\n`; + } else { + text += ` L${range.start}-${range.end}\n`; + } + } + } else { + text += 'All executable lines are covered.\n'; + } + } else { + text += `Note: Could not retrieve line-level coverage from archive.\n`; + } + } + + return { + content: [{ type: 'text', text: text.trimEnd() }], + nextStepParams: { + get_coverage_report: { xcresultPath }, + }, + }; +} + +interface LineRange { + start: number; + end: number; +} + +/** + * Parse xccov archive output to find uncovered line ranges. + * Each line starts with the line number, a colon, and a count (0 = uncovered, * = non-executable). + * Example: + * 1: * + * 2: 1 + * 3: 0 + * 4: 0 + * 5: 1 + * Lines with count 0 are uncovered. + */ +function parseUncoveredLines(output: string): LineRange[] { + const ranges: LineRange[] = []; + let currentRange: LineRange | null = null; + + for (const line of output.split('\n')) { + const match = line.match(/^\s*(\d+):\s+(\S+)/); + if (!match) continue; + + const lineNum = parseInt(match[1], 10); + const count = match[2]; + + if (count === '0') { + if (currentRange) { + currentRange.end = lineNum; + } else { + currentRange = { start: lineNum, end: lineNum }; + } + } else { + if (currentRange) { + ranges.push(currentRange); + currentRange = null; + } + } + } + + if (currentRange) { + ranges.push(currentRange); + } + + return ranges; +} + +export const schema = getFileCoverageSchema.shape; + +export const handler = createTypedTool( + getFileCoverageSchema, + get_file_coverageLogic, + getDefaultCommandExecutor, +); From 7bbfeba9329bd25ab9e77cd87e752f0380121c65 Mon Sep 17 00:00:00 2001 From: Ranga Reddy Nukala Date: Mon, 23 Feb 2026 16:53:01 -0800 Subject: [PATCH 04/14] Add coverage tools to simulator, device, macos, and swift-package workflows --- manifests/workflows/device.yaml | 2 ++ manifests/workflows/macos.yaml | 2 ++ manifests/workflows/simulator.yaml | 2 ++ manifests/workflows/swift-package.yaml | 2 ++ 4 files changed, 8 insertions(+) diff --git a/manifests/workflows/device.yaml b/manifests/workflows/device.yaml index 7a5aaa86..9dd6fdec 100644 --- a/manifests/workflows/device.yaml +++ b/manifests/workflows/device.yaml @@ -16,3 +16,5 @@ tools: - get_app_bundle_id - start_device_log_cap - stop_device_log_cap + - get_coverage_report + - get_file_coverage diff --git a/manifests/workflows/macos.yaml b/manifests/workflows/macos.yaml index c6cec69a..489d7076 100644 --- a/manifests/workflows/macos.yaml +++ b/manifests/workflows/macos.yaml @@ -13,3 +13,5 @@ tools: - discover_projs - list_schemes - show_build_settings + - get_coverage_report + - get_file_coverage diff --git a/manifests/workflows/simulator.yaml b/manifests/workflows/simulator.yaml index bd44ded9..b6fcbbc1 100644 --- a/manifests/workflows/simulator.yaml +++ b/manifests/workflows/simulator.yaml @@ -26,3 +26,5 @@ tools: - snapshot_ui - stop_sim_log_cap - start_sim_log_cap + - get_coverage_report + - get_file_coverage diff --git a/manifests/workflows/swift-package.yaml b/manifests/workflows/swift-package.yaml index 173ae76e..856e3a67 100644 --- a/manifests/workflows/swift-package.yaml +++ b/manifests/workflows/swift-package.yaml @@ -8,3 +8,5 @@ tools: - swift_package_run - swift_package_stop - swift_package_list + - get_coverage_report + - get_file_coverage From 045a31b8eb9f12016e0a2d9b0cf4ef9e37c16f67 Mon Sep 17 00:00:00 2001 From: Ranga Reddy Nukala Date: Mon, 23 Feb 2026 16:53:15 -0800 Subject: [PATCH 05/14] Update CHANGELOG and generated tool docs for coverage tools --- CHANGELOG.md | 5 +++++ docs/TOOLS-CLI.md | 34 +++++++++++++++++++++++++--------- docs/TOOLS.md | 34 +++++++++++++++++++++++++--------- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0928aea..3a1f9312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ - Added support for persisting custom environment variables in session defaults ([#235](https://github.com/getsentry/XcodeBuildMCP/pull/235) by [@kamal](https://github.com/kamal)). See [docs/SESSION_DEFAULTS.md](docs/SESSION_DEFAULTS.md#persisting-defaults). - Added Kiro client setup instructions ([#222](https://github.com/getsentry/XcodeBuildMCP/pull/222) by [@manojmahapatra](https://github.com/manojmahapatra)). +### Added + +- Added `get_coverage_report` tool to show per-target code coverage from xcresult bundles ([#227](https://github.com/getsentry/XcodeBuildMCP/issues/227)) +- Added `get_file_coverage` tool to show function-level coverage and uncovered line ranges for specific files ([#227](https://github.com/getsentry/XcodeBuildMCP/issues/227)) + ### Changed - Faster MCP startup when the Xcode IDE workflow is enabled — tools are available sooner after connecting ([#210](https://github.com/getsentry/XcodeBuildMCP/issues/210)). See [docs/XCODE_IDE_MCPBRIDGE.md](docs/XCODE_IDE_MCPBRIDGE.md). diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index 3613fece..f1e2790c 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -2,7 +2,7 @@ This document lists CLI tool names as exposed by `xcodebuildmcp `. -XcodeBuildMCP provides 73 canonical tools organized into 13 workflow groups. +XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups. ## Workflow Groups @@ -13,14 +13,24 @@ XcodeBuildMCP provides 73 canonical tools organized into 13 workflow groups. +### Code Coverage (`coverage`) +**Purpose**: View code coverage data from xcresult bundles produced by test runs. (2 tools) + +- `get-coverage-report` - Show per-target code coverage from an xcresult bundle. +- `get-file-coverage` - Show function-level coverage and uncovered line ranges for a specific file. + + + ### iOS Device Development (`device`) -**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (14 tools) +**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools) - `build` - Build for device. - `clean` - Clean build products. - `discover-projects` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. Use when project/workspace path is unknown. - `get-app-bundle-id` - Extract bundle id from .app. - `get-app-path` - Get device built app path. +- `get-coverage-report` - Defined in Code Coverage workflow. +- `get-file-coverage` - Defined in Code Coverage workflow. - `install` - Install app on device. - `launch` - Launch app on device. - `list` - List connected devices. @@ -34,7 +44,7 @@ XcodeBuildMCP provides 73 canonical tools organized into 13 workflow groups. ### iOS Simulator Development (`simulator`) -**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. (21 tools) +**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. (23 tools) - `boot` - Defined in Simulator Management workflow. - `build` - Build for iOS sim (compile-only, no launch). @@ -43,6 +53,8 @@ XcodeBuildMCP provides 73 canonical tools organized into 13 workflow groups. - `discover-projects` - Defined in iOS Device Development workflow. - `get-app-bundle-id` - Defined in iOS Device Development workflow. - `get-app-path` - Get sim built app path. +- `get-coverage-report` - Defined in Code Coverage workflow. +- `get-file-coverage` - Defined in Code Coverage workflow. - `install` - Install app on sim. - `launch-app` - Launch app on simulator. - `launch-app-with-logs` - Launch sim app with logs. @@ -85,13 +97,15 @@ XcodeBuildMCP provides 73 canonical tools organized into 13 workflow groups. ### macOS Development (`macos`) -**Purpose**: Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications. (11 tools) +**Purpose**: Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications. (13 tools) - `build` - Build macOS app. - `build-and-run` - Build and run macOS app. - `clean` - Defined in iOS Device Development workflow. - `discover-projects` - Defined in iOS Device Development workflow. - `get-app-path` - Get macOS built app path. +- `get-coverage-report` - Defined in Code Coverage workflow. +- `get-file-coverage` - Defined in Code Coverage workflow. - `get-macos-bundle-id` - Extract bundle id from macOS .app. - `launch` - Launch macOS app. - `list-schemes` - Defined in iOS Device Development workflow. @@ -142,10 +156,12 @@ XcodeBuildMCP provides 73 canonical tools organized into 13 workflow groups. ### Swift Package Development (`swift-package`) -**Purpose**: Build, test, run and manage Swift Package Manager projects. (6 tools) +**Purpose**: Build, test, run and manage Swift Package Manager projects. (8 tools) - `build` - swift package target build. - `clean` - swift package clean. +- `get-coverage-report` - Defined in Code Coverage workflow. +- `get-file-coverage` - Defined in Code Coverage workflow. - `list` - List SwiftPM processes. - `run` - swift package target run. - `stop` - Stop SwiftPM run. @@ -183,10 +199,10 @@ XcodeBuildMCP provides 73 canonical tools organized into 13 workflow groups. ## Summary Statistics -- **Canonical Tools**: 73 -- **Total Tools**: 97 -- **Workflow Groups**: 13 +- **Canonical Tools**: 75 +- **Total Tools**: 107 +- **Workflow Groups**: 14 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-02T13:10:22.681Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-03T09:47:23.582Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 1df3c490..af243c8b 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,6 +1,6 @@ # XcodeBuildMCP MCP Tools Reference -This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 79 canonical tools organized into 15 workflow groups for comprehensive Apple development workflows. +This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 81 canonical tools organized into 16 workflow groups for comprehensive Apple development workflows. ## Workflow Groups @@ -11,14 +11,24 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov +### Code Coverage (`coverage`) +**Purpose**: View code coverage data from xcresult bundles produced by test runs. (2 tools) + +- `get_coverage_report` - Show per-target code coverage from an xcresult bundle. +- `get_file_coverage` - Show function-level coverage and uncovered line ranges for a specific file. + + + ### iOS Device Development (`device`) -**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (14 tools) +**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools) - `build_device` - Build for device. - `clean` - Clean build products. - `discover_projs` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. Use when project/workspace path is unknown. - `get_app_bundle_id` - Extract bundle id from .app. +- `get_coverage_report` - Defined in Code Coverage workflow. - `get_device_app_path` - Get device built app path. +- `get_file_coverage` - Defined in Code Coverage workflow. - `install_app_device` - Install app on device. - `launch_app_device` - Launch app on device. - `list_devices` - List connected devices. @@ -32,7 +42,7 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov ### iOS Simulator Development (`simulator`) -**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. (21 tools) +**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. (23 tools) - `boot_sim` - Defined in Simulator Management workflow. - `build_run_sim` - Build, install, and launch on iOS Simulator; boots simulator and attempts to open Simulator.app as needed. Preferred single-step run tool when defaults are set. @@ -40,6 +50,8 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov - `clean` - Defined in iOS Device Development workflow. - `discover_projs` - Defined in iOS Device Development workflow. - `get_app_bundle_id` - Defined in iOS Device Development workflow. +- `get_coverage_report` - Defined in Code Coverage workflow. +- `get_file_coverage` - Defined in Code Coverage workflow. - `get_sim_app_path` - Get sim built app path. - `install_app_sim` - Install app on sim. - `launch_app_logs_sim` - Launch sim app with logs. @@ -83,12 +95,14 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov ### macOS Development (`macos`) -**Purpose**: Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications. (11 tools) +**Purpose**: Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications. (13 tools) - `build_macos` - Build macOS app. - `build_run_macos` - Build and run macOS app. - `clean` - Defined in iOS Device Development workflow. - `discover_projs` - Defined in iOS Device Development workflow. +- `get_coverage_report` - Defined in Code Coverage workflow. +- `get_file_coverage` - Defined in Code Coverage workflow. - `get_mac_app_path` - Get macOS built app path. - `get_mac_bundle_id` - Extract bundle id from macOS .app. - `launch_mac_app` - Launch macOS app. @@ -151,8 +165,10 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov ### Swift Package Development (`swift-package`) -**Purpose**: Build, test, run and manage Swift Package Manager projects. (6 tools) +**Purpose**: Build, test, run and manage Swift Package Manager projects. (8 tools) +- `get_coverage_report` - Defined in Code Coverage workflow. +- `get_file_coverage` - Defined in Code Coverage workflow. - `swift_package_build` - swift package target build. - `swift_package_clean` - swift package clean. - `swift_package_list` - List SwiftPM processes. @@ -199,10 +215,10 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov ## Summary Statistics -- **Canonical Tools**: 79 -- **Total Tools**: 103 -- **Workflow Groups**: 15 +- **Canonical Tools**: 81 +- **Total Tools**: 113 +- **Workflow Groups**: 16 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-02T13:10:22.681Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-03T09:47:23.582Z UTC* From 04a0b29e17fab95972717531855cc285e1c1c42f Mon Sep 17 00:00:00 2001 From: Ranga Reddy Nukala Date: Mon, 23 Feb 2026 17:34:10 -0800 Subject: [PATCH 06/14] Add unit tests for coverage tools --- .../__tests__/get_coverage_report.test.ts | 270 ++++++++++++ .../__tests__/get_file_coverage.test.ts | 384 ++++++++++++++++++ 2 files changed, 654 insertions(+) create mode 100644 src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts create mode 100644 src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts diff --git a/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts b/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts new file mode 100644 index 00000000..56fc2fd1 --- /dev/null +++ b/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts @@ -0,0 +1,270 @@ +/** + * Tests for get_coverage_report tool + * Covers happy-path, target filtering, showFiles, and failure paths + */ + +import { describe, it, expect } from 'vitest'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { schema, handler, get_coverage_reportLogic } from '../get_coverage_report.ts'; + +const sampleTargets = [ + { name: 'MyApp.app', coveredLines: 100, executableLines: 200, lineCoverage: 0.5 }, + { name: 'Core', coveredLines: 50, executableLines: 500, lineCoverage: 0.1 }, + { name: 'MyAppTests.xctest', coveredLines: 30, executableLines: 30, lineCoverage: 1.0 }, +]; + +const sampleTargetsWithFiles = [ + { + name: 'MyApp.app', + coveredLines: 100, + executableLines: 200, + lineCoverage: 0.5, + files: [ + { name: 'AppDelegate.swift', path: '/src/AppDelegate.swift', coveredLines: 10, executableLines: 50, lineCoverage: 0.2 }, + { name: 'ViewModel.swift', path: '/src/ViewModel.swift', coveredLines: 90, executableLines: 150, lineCoverage: 0.6 }, + ], + }, + { + name: 'Core', + coveredLines: 50, + executableLines: 500, + lineCoverage: 0.1, + files: [ + { name: 'Service.swift', path: '/src/Service.swift', coveredLines: 0, executableLines: 300, lineCoverage: 0 }, + { name: 'Model.swift', path: '/src/Model.swift', coveredLines: 50, executableLines: 200, lineCoverage: 0.25 }, + ], + }, +]; + +describe('get_coverage_report', () => { + describe('Export Validation', () => { + it('should export get_coverage_reportLogic function', () => { + expect(typeof get_coverage_reportLogic).toBe('function'); + }); + + it('should export handler function', () => { + expect(typeof handler).toBe('function'); + }); + + it('should export schema with expected keys', () => { + expect(Object.keys(schema)).toContain('xcresultPath'); + expect(Object.keys(schema)).toContain('target'); + expect(Object.keys(schema)).toContain('showFiles'); + }); + }); + + describe('Command Generation', () => { + it('should use --only-targets when showFiles is false', async () => { + const commands: string[][] = []; + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleTargets), + onExecute: (command) => { commands.push(command); }, + }); + + await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + + expect(commands).toHaveLength(1); + expect(commands[0]).toContain('--only-targets'); + expect(commands[0]).toContain('--json'); + expect(commands[0]).toContain('/tmp/test.xcresult'); + }); + + it('should omit --only-targets when showFiles is true', async () => { + const commands: string[][] = []; + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleTargetsWithFiles), + onExecute: (command) => { commands.push(command); }, + }); + + await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor); + + expect(commands).toHaveLength(1); + expect(commands[0]).not.toContain('--only-targets'); + }); + }); + + describe('Happy Path', () => { + it('should return coverage report with all targets sorted by coverage', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleTargets), + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + + 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(text).toContain('Overall: 24.7%'); + expect(text).toContain('180/730 lines'); + // Should be sorted ascending: Core (10%), MyApp (50%), Tests (100%) + const coreIdx = text.indexOf('Core'); + const appIdx = text.indexOf('MyApp.app'); + const testIdx = text.indexOf('MyAppTests.xctest'); + expect(coreIdx).toBeLessThan(appIdx); + expect(appIdx).toBeLessThan(testIdx); + }); + + it('should include nextStepParams with xcresultPath', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleTargets), + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + + expect(result.nextStepParams).toEqual({ + get_file_coverage: { xcresultPath: '/tmp/test.xcresult' }, + }); + }); + + it('should handle nested targets format', async () => { + const nestedData = { targets: sampleTargets }; + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(nestedData), + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + + expect(result.isError).toBeUndefined(); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Core: 10.0%'); + expect(text).toContain('MyApp.app: 50.0%'); + }); + }); + + describe('Target Filtering', () => { + it('should filter targets by substring match', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleTargets), + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'MyApp', showFiles: false }, mockExecutor); + + 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); + }); + + it('should filter case-insensitively', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleTargets), + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'core', showFiles: false }, mockExecutor); + + expect(result.isError).toBeUndefined(); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Core: 10.0%'); + }); + + it('should return error when no targets match filter', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleTargets), + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'NonExistent', showFiles: false }, mockExecutor); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('No targets found matching "NonExistent"'); + }); + }); + + describe('showFiles', () => { + it('should include per-file breakdown under each target', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleTargetsWithFiles), + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor); + + 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%'); + }); + + it('should sort files by coverage ascending within each target', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleTargetsWithFiles), + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor); + + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + // Under MyApp.app: AppDelegate (20%) before ViewModel (60%) + const appDelegateIdx = text.indexOf('AppDelegate.swift'); + const viewModelIdx = text.indexOf('ViewModel.swift'); + expect(appDelegateIdx).toBeLessThan(viewModelIdx); + }); + }); + + describe('Failure Paths', () => { + it('should return error when xccov command fails', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Failed to load result bundle', + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/bad.xcresult', showFiles: false }, mockExecutor); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Failed to get coverage report'); + expect(text).toContain('Failed to load result bundle'); + }); + + it('should return error when JSON parsing fails', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'not valid json', + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Failed to parse coverage JSON output'); + }); + + it('should return error when data format is unexpected', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ unexpected: 'format' }), + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Unexpected coverage data format'); + }); + + it('should return error when targets array is empty', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify([]), + }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + 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 new file mode 100644 index 00000000..7ecc6ae5 --- /dev/null +++ b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts @@ -0,0 +1,384 @@ +/** + * Tests for get_file_coverage tool + * Covers happy-path, showLines, uncovered line parsing, and failure paths + */ + +import { describe, it, expect } from 'vitest'; +import { + createMockExecutor, + createCommandMatchingMockExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { schema, handler, get_file_coverageLogic } from '../get_file_coverage.ts'; + +const sampleFunctionsJson = [ + { + file: '/src/MyApp/ViewModel.swift', + functions: [ + { name: 'init()', coveredLines: 5, executableLines: 5, executionCount: 3, lineCoverage: 1.0, lineNumber: 10 }, + { name: 'loadData()', coveredLines: 8, executableLines: 12, executionCount: 2, lineCoverage: 0.667, lineNumber: 20 }, + { name: 'reset()', coveredLines: 0, executableLines: 4, executionCount: 0, lineCoverage: 0, lineNumber: 40 }, + ], + }, +]; + +const sampleArchiveOutput = [ + ' 1: *', + ' 2: 1', + ' 3: 1', + ' 4: 0', + ' 5: 0', + ' 6: 0', + ' 7: 1', + ' 8: *', + ' 9: 0', + ' 10: 1', +].join('\n'); + +describe('get_file_coverage', () => { + describe('Export Validation', () => { + it('should export get_file_coverageLogic function', () => { + expect(typeof get_file_coverageLogic).toBe('function'); + }); + + it('should export handler function', () => { + expect(typeof handler).toBe('function'); + }); + + it('should export schema with expected keys', () => { + expect(Object.keys(schema)).toContain('xcresultPath'); + expect(Object.keys(schema)).toContain('file'); + expect(Object.keys(schema)).toContain('showLines'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct functions-for-file command', async () => { + const commands: string[][] = []; + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + onExecute: (command) => { commands.push(command); }, + }); + + await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, + mockExecutor, + ); + + expect(commands).toHaveLength(1); + expect(commands[0]).toEqual([ + 'xcrun', 'xccov', 'view', '--report', + '--functions-for-file', 'ViewModel.swift', + '--json', '/tmp/test.xcresult', + ]); + }); + + it('should issue archive command when showLines is true', async () => { + const commands: string[][] = []; + let callCount = 0; + const mockExecutor = async ( + command: string[], + _logPrefix?: string, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, + ) => { + commands.push(command); + callCount++; + if (callCount === 1) { + return { success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }; + } + return { success: true, output: sampleArchiveOutput, exitCode: 0 }; + }; + + await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, + mockExecutor, + ); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual([ + 'xcrun', 'xccov', 'view', '--archive', + '--file', '/src/MyApp/ViewModel.swift', + '/tmp/test.xcresult', + ]); + }); + }); + + describe('Happy Path', () => { + it('should return function-level coverage with file summary', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, + mockExecutor, + ); + + expect(result.isError).toBeUndefined(); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + // File summary computed from functions: 13/21 lines + 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 () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, + mockExecutor, + ); + + 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()'); + }); + + it('should sort functions by line number', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, + mockExecutor, + ); + + 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); + }); + + it('should list uncovered functions summary', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, + mockExecutor, + ); + + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Uncovered functions (1):'); + expect(text).toContain('- reset() (line 40)'); + }); + + it('should include nextStepParams', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, + mockExecutor, + ); + + expect(result.nextStepParams).toEqual({ + get_coverage_report: { xcresultPath: '/tmp/test.xcresult' }, + }); + }); + }); + + describe('Nested targets format', () => { + it('should handle { targets: [{ files: [...] }] } format', async () => { + const nestedData = { + targets: [ + { + files: [ + { + path: '/src/Model.swift', + name: 'Model.swift', + coveredLines: 10, + executableLines: 20, + lineCoverage: 0.5, + functions: [ + { name: 'save()', coveredLines: 10, executableLines: 20, executionCount: 5, lineCoverage: 0.5, lineNumber: 1 }, + ], + }, + ], + }, + ], + }; + + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(nestedData), + }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'Model.swift', showLines: false }, + mockExecutor, + ); + + expect(result.isError).toBeUndefined(); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('File: /src/Model.swift'); + expect(text).toContain('50.0%'); + }); + }); + + describe('showLines', () => { + it('should include uncovered line ranges from archive output', async () => { + let callCount = 0; + const mockExecutor = async ( + _command: string[], + _logPrefix?: string, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, + ) => { + callCount++; + if (callCount === 1) { + return { success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }; + } + return { success: true, output: sampleArchiveOutput, exitCode: 0 }; + }; + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, + mockExecutor, + ); + + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Uncovered line ranges:'); + expect(text).toContain('L4-6'); + expect(text).toContain('L9'); + }); + + it('should show "All executable lines are covered" when no uncovered lines', async () => { + const allCoveredArchive = ' 1: *\n 2: 1\n 3: 1\n 4: 1\n'; + let callCount = 0; + const mockExecutor = async ( + _command: string[], + _logPrefix?: string, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, + ) => { + callCount++; + if (callCount === 1) { + return { success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }; + } + return { success: true, output: allCoveredArchive, exitCode: 0 }; + }; + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, + mockExecutor, + ); + + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('All executable lines are covered'); + }); + + it('should handle archive command failure gracefully', async () => { + let callCount = 0; + const mockExecutor = async ( + _command: string[], + _logPrefix?: string, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, + ) => { + callCount++; + if (callCount === 1) { + return { success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }; + } + return { success: false, output: '', error: 'archive error', exitCode: 1 }; + }; + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, + mockExecutor, + ); + + expect(result.isError).toBeUndefined(); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Could not retrieve line-level coverage from archive'); + }); + }); + + describe('Failure Paths', () => { + it('should return error when functions-for-file command fails', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Failed to load result bundle', + }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/bad.xcresult', file: 'Foo.swift', showLines: false }, + mockExecutor, + ); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Failed to get file coverage'); + expect(text).toContain('Failed to load result bundle'); + }); + + it('should return error when JSON parsing fails', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'not json', + }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'Foo.swift', showLines: false }, + mockExecutor, + ); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('Failed to parse coverage JSON output'); + }); + + it('should return error when no file entries found', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify([]), + }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'Missing.swift', showLines: false }, + mockExecutor, + ); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('No coverage data found for "Missing.swift"'); + }); + + it('should handle file entry with no functions gracefully', async () => { + const noFunctions = [{ file: '/src/Empty.swift', functions: [] }]; + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(noFunctions), + }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'Empty.swift', showLines: false }, + mockExecutor, + ); + + expect(result.isError).toBeUndefined(); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('File: /src/Empty.swift'); + expect(text).toContain('Coverage: 0.0%'); + expect(text).toContain('0/0 lines'); + }); + }); +}); From c946a5be705b3fc5d6d89ae912c34185f1086feb Mon Sep 17 00:00:00 2001 From: Ranga Reddy Nukala Date: Mon, 23 Feb 2026 17:42:20 -0800 Subject: [PATCH 07/14] Fix missing Array.isArray guard on targets field in get_file_coverage --- .../coverage/__tests__/get_file_coverage.test.ts | 16 ++++++++++++++++ src/mcp/tools/coverage/get_file_coverage.ts | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) 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 7ecc6ae5..66906b10 100644 --- a/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts @@ -362,6 +362,22 @@ describe('get_file_coverage', () => { expect(text).toContain('No coverage data found for "Missing.swift"'); }); + it('should return no data when targets field is not an array', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ targets: 'not-an-array' }), + }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/test.xcresult', file: 'Foo.swift', showLines: false }, + mockExecutor, + ); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('No coverage data found for "Foo.swift"'); + }); + it('should handle file entry with no functions gracefully', async () => { const noFunctions = [{ file: '/src/Empty.swift', functions: [] }]; const mockExecutor = createMockExecutor({ diff --git a/src/mcp/tools/coverage/get_file_coverage.ts b/src/mcp/tools/coverage/get_file_coverage.ts index dea7c88b..dd9b3092 100644 --- a/src/mcp/tools/coverage/get_file_coverage.ts +++ b/src/mcp/tools/coverage/get_file_coverage.ts @@ -113,7 +113,12 @@ export async function get_file_coverageLogic( if (Array.isArray(data)) { fileEntries = (data as RawFileEntry[]).map(normalizeFileEntry); - } else if (typeof data === 'object' && data !== null && 'targets' in data) { + } else if ( + typeof data === 'object' && + data !== null && + 'targets' in data && + Array.isArray((data as { targets: unknown }).targets) + ) { const targets = (data as { targets: { files?: RawFileEntry[] }[] }).targets; for (const t of targets) { if (t.files) { From 85c5b6e52e861570241c35fa0d7d1d043717582f Mon Sep 17 00:00:00 2001 From: Ranga Reddy Nukala Date: Tue, 24 Feb 2026 22:05:47 -0800 Subject: [PATCH 08/14] Fix missing process property in inline mock executors for typecheck Use createMockCommandResponse in get_file_coverage tests so inline mock executors return the required process field from CommandResponse. --- .../__tests__/get_file_coverage.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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 66906b10..acbf3a7d 100644 --- a/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest'; import { createMockExecutor, + createMockCommandResponse, createCommandMatchingMockExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, get_file_coverageLogic } from '../get_file_coverage.ts'; @@ -86,9 +87,9 @@ describe('get_file_coverage', () => { commands.push(command); callCount++; if (callCount === 1) { - return { success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }; + return createMockCommandResponse({ success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }); } - return { success: true, output: sampleArchiveOutput, exitCode: 0 }; + return createMockCommandResponse({ success: true, output: sampleArchiveOutput, exitCode: 0 }); }; await get_file_coverageLogic( @@ -243,9 +244,9 @@ describe('get_file_coverage', () => { ) => { callCount++; if (callCount === 1) { - return { success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }; + return createMockCommandResponse({ success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }); } - return { success: true, output: sampleArchiveOutput, exitCode: 0 }; + return createMockCommandResponse({ success: true, output: sampleArchiveOutput, exitCode: 0 }); }; const result = await get_file_coverageLogic( @@ -271,9 +272,9 @@ describe('get_file_coverage', () => { ) => { callCount++; if (callCount === 1) { - return { success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }; + return createMockCommandResponse({ success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }); } - return { success: true, output: allCoveredArchive, exitCode: 0 }; + return createMockCommandResponse({ success: true, output: allCoveredArchive, exitCode: 0 }); }; const result = await get_file_coverageLogic( @@ -296,9 +297,9 @@ describe('get_file_coverage', () => { ) => { callCount++; if (callCount === 1) { - return { success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }; + return createMockCommandResponse({ success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }); } - return { success: false, output: '', error: 'archive error', exitCode: 1 }; + return createMockCommandResponse({ success: false, output: '', error: 'archive error', exitCode: 1 }); }; const result = await get_file_coverageLogic( From b20302e160643e3465bff29989aa8503ae8374b4 Mon Sep 17 00:00:00 2001 From: Ranga Reddy Nukala Date: Wed, 25 Feb 2026 02:17:22 -0800 Subject: [PATCH 09/14] Add file path label to uncovered line ranges in showLines output When showLines is true the archive command only runs for the first matched file entry. The uncovered-ranges block now includes the file path so readers know which file the ranges belong to. --- src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts | 2 +- src/mcp/tools/coverage/get_file_coverage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 acbf3a7d..96f5f248 100644 --- a/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts @@ -255,7 +255,7 @@ describe('get_file_coverage', () => { ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; - expect(text).toContain('Uncovered line ranges:'); + expect(text).toContain('Uncovered line ranges (/src/MyApp/ViewModel.swift):'); expect(text).toContain('L4-6'); expect(text).toContain('L9'); }); diff --git a/src/mcp/tools/coverage/get_file_coverage.ts b/src/mcp/tools/coverage/get_file_coverage.ts index dd9b3092..bd650c9a 100644 --- a/src/mcp/tools/coverage/get_file_coverage.ts +++ b/src/mcp/tools/coverage/get_file_coverage.ts @@ -185,7 +185,7 @@ export async function get_file_coverageLogic( if (archiveResult.success && archiveResult.output) { const uncoveredRanges = parseUncoveredLines(archiveResult.output); if (uncoveredRanges.length > 0) { - text += 'Uncovered line ranges:\n'; + text += `Uncovered line ranges (${filePath}):\n`; for (const range of uncoveredRanges) { if (range.start === range.end) { text += ` L${range.start}\n`; From 9f25d94e99936e3aee340497a1163501439fc7ba Mon Sep 17 00:00:00 2001 From: Ranga Reddy Nukala Date: Wed, 25 Feb 2026 08:52:53 -0800 Subject: [PATCH 10/14] Validate individual coverage target objects before processing Add isValidCoverageTarget type guard to filter out malformed objects from xccov JSON output, preventing runtime crashes on missing properties. --- src/mcp/tools/coverage/get_coverage_report.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/mcp/tools/coverage/get_coverage_report.ts b/src/mcp/tools/coverage/get_coverage_report.ts index ea76e53e..1404672a 100644 --- a/src/mcp/tools/coverage/get_coverage_report.ts +++ b/src/mcp/tools/coverage/get_coverage_report.ts @@ -40,6 +40,17 @@ interface CoverageTarget { files?: CoverageFile[]; } +function isValidCoverageTarget(value: unknown): value is CoverageTarget { + return ( + typeof value === 'object' && + value !== null && + typeof (value as CoverageTarget).name === 'string' && + typeof (value as CoverageTarget).coveredLines === 'number' && + typeof (value as CoverageTarget).executableLines === 'number' && + typeof (value as CoverageTarget).lineCoverage === 'number' + ); +} + export async function get_coverage_reportLogic( params: GetCoverageReportParams, executor: CommandExecutor, @@ -84,16 +95,16 @@ export async function get_coverage_reportLogic( } // Validate structure: expect an array of target objects or { targets: [...] } - let targets: CoverageTarget[] = []; + let rawTargets: unknown[] = []; if (Array.isArray(data)) { - targets = data as CoverageTarget[]; + rawTargets = data; } else if ( typeof data === 'object' && data !== null && 'targets' in data && Array.isArray((data as { targets: unknown }).targets) ) { - targets = (data as { targets: CoverageTarget[] }).targets; + rawTargets = (data as { targets: unknown[] }).targets; } else { return { content: [ @@ -106,6 +117,8 @@ export async function get_coverage_reportLogic( }; } + let targets = rawTargets.filter(isValidCoverageTarget); + // Filter by target name if specified if (target) { const lowerTarget = target.toLowerCase(); From 9372c335f973cbde9d19611013a8a36305b46de8 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 3 Mar 2026 09:45:27 +0000 Subject: [PATCH 11/14] Update docs --- docs/TOOLS-CLI.md | 2 +- docs/TOOLS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index f1e2790c..b0819971 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -205,4 +205,4 @@ XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups. --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-03T09:47:23.582Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-03T09:47:33.422Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index af243c8b..62b3a449 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -221,4 +221,4 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-03T09:47:23.582Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-03T09:47:33.422Z UTC* From 1361f2831f838d306a408332f093d0393a7e50f8 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 3 Mar 2026 09:56:44 +0000 Subject: [PATCH 12/14] Validate xcresultPath exists before invoking xccov Add validateFileExists check for xcresultPath in both get_coverage_report and get_file_coverage, consistent with other tools that validate file paths (install_app_sim, launch_mac_app). --- .../__tests__/get_coverage_report.test.ts | 44 ++++++++++++------- .../__tests__/get_file_coverage.test.ts | 35 +++++++++++++++ src/mcp/tools/coverage/get_coverage_report.ts | 9 +++- src/mcp/tools/coverage/get_file_coverage.ts | 9 +++- 4 files changed, 80 insertions(+), 17 deletions(-) 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 56fc2fd1..4bdb5d60 100644 --- a/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts @@ -4,7 +4,7 @@ */ import { describe, it, expect } from 'vitest'; -import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { createMockExecutor, createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, get_coverage_reportLogic } from '../get_coverage_report.ts'; const sampleTargets = [ @@ -36,6 +36,8 @@ const sampleTargetsWithFiles = [ }, ]; +const mockFileSystem = createMockFileSystemExecutor({ existsSync: () => true }); + describe('get_coverage_report', () => { describe('Export Validation', () => { it('should export get_coverage_reportLogic function', () => { @@ -62,7 +64,7 @@ describe('get_coverage_report', () => { onExecute: (command) => { commands.push(command); }, }); - await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); expect(commands).toHaveLength(1); expect(commands[0]).toContain('--only-targets'); @@ -78,7 +80,7 @@ describe('get_coverage_report', () => { onExecute: (command) => { commands.push(command); }, }); - await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor); + await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor, mockFileSystem); expect(commands).toHaveLength(1); expect(commands[0]).not.toContain('--only-targets'); @@ -92,7 +94,7 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); expect(result.isError).toBeUndefined(); expect(result.content).toHaveLength(1); @@ -114,7 +116,7 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); expect(result.nextStepParams).toEqual({ get_file_coverage: { xcresultPath: '/tmp/test.xcresult' }, @@ -128,7 +130,7 @@ describe('get_coverage_report', () => { output: JSON.stringify(nestedData), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); expect(result.isError).toBeUndefined(); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -144,7 +146,7 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'MyApp', showFiles: false }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'MyApp', showFiles: false }, mockExecutor, mockFileSystem); expect(result.isError).toBeUndefined(); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -159,7 +161,7 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'core', showFiles: false }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'core', showFiles: false }, mockExecutor, mockFileSystem); expect(result.isError).toBeUndefined(); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -172,7 +174,7 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'NonExistent', showFiles: false }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'NonExistent', showFiles: false }, mockExecutor, mockFileSystem); expect(result.isError).toBe(true); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -187,7 +189,7 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargetsWithFiles), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor, mockFileSystem); expect(result.isError).toBeUndefined(); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -203,7 +205,7 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargetsWithFiles), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor, mockFileSystem); const text = result.content[0].type === 'text' ? result.content[0].text : ''; // Under MyApp.app: AppDelegate (20%) before ViewModel (60%) @@ -214,13 +216,25 @@ describe('get_coverage_report', () => { }); describe('Failure Paths', () => { + it('should return error when xcresult path does not exist', async () => { + const missingFs = createMockFileSystemExecutor({ existsSync: () => false }); + const mockExecutor = createMockExecutor({ success: true, output: '{}' }); + + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/missing.xcresult', showFiles: false }, mockExecutor, missingFs); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('File not found'); + expect(text).toContain('/tmp/missing.xcresult'); + }); + it('should return error when xccov command fails', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Failed to load result bundle', }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/bad.xcresult', showFiles: false }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/bad.xcresult', showFiles: false }, mockExecutor, mockFileSystem); expect(result.isError).toBe(true); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -234,7 +248,7 @@ describe('get_coverage_report', () => { output: 'not valid json', }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); expect(result.isError).toBe(true); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -247,7 +261,7 @@ describe('get_coverage_report', () => { output: JSON.stringify({ unexpected: 'format' }), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); expect(result.isError).toBe(true); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -260,7 +274,7 @@ describe('get_coverage_report', () => { output: JSON.stringify([]), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor); + const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); expect(result.isError).toBe(true); const text = result.content[0].type === 'text' ? result.content[0].text : ''; 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 96f5f248..ebd572b2 100644 --- a/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts @@ -8,6 +8,7 @@ import { createMockExecutor, createMockCommandResponse, createCommandMatchingMockExecutor, + createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, get_file_coverageLogic } from '../get_file_coverage.ts'; @@ -35,6 +36,8 @@ const sampleArchiveOutput = [ ' 10: 1', ].join('\n'); +const mockFileSystem = createMockFileSystemExecutor({ existsSync: () => true }); + describe('get_file_coverage', () => { describe('Export Validation', () => { it('should export get_file_coverageLogic function', () => { @@ -64,6 +67,7 @@ describe('get_file_coverage', () => { await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, mockExecutor, + mockFileSystem, ); expect(commands).toHaveLength(1); @@ -95,6 +99,7 @@ describe('get_file_coverage', () => { await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, mockExecutor, + mockFileSystem, ); expect(commands).toHaveLength(2); @@ -116,6 +121,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, mockExecutor, + mockFileSystem, ); expect(result.isError).toBeUndefined(); @@ -135,6 +141,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, mockExecutor, + mockFileSystem, ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -151,6 +158,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, mockExecutor, + mockFileSystem, ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -170,6 +178,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, mockExecutor, + mockFileSystem, ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -186,6 +195,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, mockExecutor, + mockFileSystem, ); expect(result.nextStepParams).toEqual({ @@ -223,6 +233,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Model.swift', showLines: false }, mockExecutor, + mockFileSystem, ); expect(result.isError).toBeUndefined(); @@ -252,6 +263,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, mockExecutor, + mockFileSystem, ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -280,6 +292,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, mockExecutor, + mockFileSystem, ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -305,6 +318,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, mockExecutor, + mockFileSystem, ); expect(result.isError).toBeUndefined(); @@ -314,6 +328,22 @@ describe('get_file_coverage', () => { }); describe('Failure Paths', () => { + it('should return error when xcresult path does not exist', async () => { + const missingFs = createMockFileSystemExecutor({ existsSync: () => false }); + const mockExecutor = createMockExecutor({ success: true, output: '{}' }); + + const result = await get_file_coverageLogic( + { xcresultPath: '/tmp/missing.xcresult', file: 'Foo.swift', showLines: false }, + mockExecutor, + missingFs, + ); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('File not found'); + expect(text).toContain('/tmp/missing.xcresult'); + }); + it('should return error when functions-for-file command fails', async () => { const mockExecutor = createMockExecutor({ success: false, @@ -323,6 +353,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/bad.xcresult', file: 'Foo.swift', showLines: false }, mockExecutor, + mockFileSystem, ); expect(result.isError).toBe(true); @@ -340,6 +371,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Foo.swift', showLines: false }, mockExecutor, + mockFileSystem, ); expect(result.isError).toBe(true); @@ -356,6 +388,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Missing.swift', showLines: false }, mockExecutor, + mockFileSystem, ); expect(result.isError).toBe(true); @@ -372,6 +405,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Foo.swift', showLines: false }, mockExecutor, + mockFileSystem, ); expect(result.isError).toBe(true); @@ -389,6 +423,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Empty.swift', showLines: false }, mockExecutor, + mockFileSystem, ); expect(result.isError).toBeUndefined(); diff --git a/src/mcp/tools/coverage/get_coverage_report.ts b/src/mcp/tools/coverage/get_coverage_report.ts index 1404672a..e8c145c9 100644 --- a/src/mcp/tools/coverage/get_coverage_report.ts +++ b/src/mcp/tools/coverage/get_coverage_report.ts @@ -8,7 +8,8 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { validateFileExists } from '../../../utils/validation/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; @@ -54,9 +55,15 @@ function isValidCoverageTarget(value: unknown): value is CoverageTarget { export async function get_coverage_reportLogic( params: GetCoverageReportParams, executor: CommandExecutor, + fileSystem?: FileSystemExecutor, ): Promise { const { xcresultPath, target, showFiles } = params; + const fileExistsValidation = validateFileExists(xcresultPath, fileSystem); + if (!fileExistsValidation.isValid) { + return fileExistsValidation.errorResponse!; + } + log('info', `Getting coverage report from: ${xcresultPath}`); const cmd = ['xcrun', 'xccov', 'view', '--report']; diff --git a/src/mcp/tools/coverage/get_file_coverage.ts b/src/mcp/tools/coverage/get_file_coverage.ts index bd650c9a..f9928923 100644 --- a/src/mcp/tools/coverage/get_file_coverage.ts +++ b/src/mcp/tools/coverage/get_file_coverage.ts @@ -8,7 +8,8 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { validateFileExists } from '../../../utils/validation/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; @@ -66,9 +67,15 @@ function normalizeFileEntry(raw: RawFileEntry): FileFunctionCoverage { export async function get_file_coverageLogic( params: GetFileCoverageParams, executor: CommandExecutor, + fileSystem?: FileSystemExecutor, ): Promise { const { xcresultPath, file, showLines } = params; + const fileExistsValidation = validateFileExists(xcresultPath, fileSystem); + if (!fileExistsValidation.isValid) { + return fileExistsValidation.errorResponse!; + } + log('info', `Getting file coverage for "${file}" from: ${xcresultPath}`); // Get function-level coverage From b490e575c61a101079a603369f09e2d887c8bb1a Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 3 Mar 2026 10:28:03 +0000 Subject: [PATCH 13/14] fix(coverage): Inject filesystem into coverage tool handlers Use createTypedToolWithContext so coverage handlers receive both command and filesystem executors. This keeps handler execution aligned with dependency injection instead of falling back to direct fs checks. Update coverage tests to use context-based logic signatures and add handler-path DI assertions for both tools. --- .../__tests__/get_coverage_report.test.ts | 166 ++++++++++++--- .../__tests__/get_file_coverage.test.ts | 199 +++++++++++++----- src/mcp/tools/coverage/get_coverage_report.ts | 23 +- src/mcp/tools/coverage/get_file_coverage.ts | 25 ++- 4 files changed, 320 insertions(+), 93 deletions(-) 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 4bdb5d60..1f715919 100644 --- a/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_coverage_report.test.ts @@ -3,8 +3,13 @@ * Covers happy-path, target filtering, showFiles, and failure paths */ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect } from 'vitest'; import { createMockExecutor, createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; +import { + __setTestCommandExecutorOverride, + __setTestFileSystemExecutorOverride, + __clearTestExecutorOverrides, +} from '../../../../utils/execution/index.ts'; import { schema, handler, get_coverage_reportLogic } from '../get_coverage_report.ts'; const sampleTargets = [ @@ -20,8 +25,20 @@ const sampleTargetsWithFiles = [ executableLines: 200, lineCoverage: 0.5, files: [ - { name: 'AppDelegate.swift', path: '/src/AppDelegate.swift', coveredLines: 10, executableLines: 50, lineCoverage: 0.2 }, - { name: 'ViewModel.swift', path: '/src/ViewModel.swift', coveredLines: 90, executableLines: 150, lineCoverage: 0.6 }, + { + name: 'AppDelegate.swift', + path: '/src/AppDelegate.swift', + coveredLines: 10, + executableLines: 50, + lineCoverage: 0.2, + }, + { + name: 'ViewModel.swift', + path: '/src/ViewModel.swift', + coveredLines: 90, + executableLines: 150, + lineCoverage: 0.6, + }, ], }, { @@ -30,14 +47,30 @@ const sampleTargetsWithFiles = [ executableLines: 500, lineCoverage: 0.1, files: [ - { name: 'Service.swift', path: '/src/Service.swift', coveredLines: 0, executableLines: 300, lineCoverage: 0 }, - { name: 'Model.swift', path: '/src/Model.swift', coveredLines: 50, executableLines: 200, lineCoverage: 0.25 }, + { + name: 'Service.swift', + path: '/src/Service.swift', + coveredLines: 0, + executableLines: 300, + lineCoverage: 0, + }, + { + name: 'Model.swift', + path: '/src/Model.swift', + coveredLines: 50, + executableLines: 200, + lineCoverage: 0.25, + }, ], }, ]; const mockFileSystem = createMockFileSystemExecutor({ existsSync: () => true }); +afterEach(() => { + __clearTestExecutorOverrides(); +}); + describe('get_coverage_report', () => { describe('Export Validation', () => { it('should export get_coverage_reportLogic function', () => { @@ -55,16 +88,59 @@ describe('get_coverage_report', () => { }); }); + describe('Handler DI', () => { + it('should use injected fileSystem from handler context', async () => { + const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify(sampleTargets) }); + const missingFs = createMockFileSystemExecutor({ existsSync: () => false }); + + __setTestCommandExecutorOverride(mockExecutor); + __setTestFileSystemExecutorOverride(missingFs); + + 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 : ''; + expect(text).toContain('File not found'); + }); + + it('should use injected command executor on handler happy path', async () => { + const commands: string[][] = []; + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleTargets), + onExecute: (command) => { + commands.push(command); + }, + }); + const existingFs = createMockFileSystemExecutor({ existsSync: () => true }); + + __setTestCommandExecutorOverride(mockExecutor); + __setTestFileSystemExecutorOverride(existingFs); + + const result = await handler({ xcresultPath: '/tmp/test.xcresult' }); + + expect(result.isError).toBeUndefined(); + expect(commands).toHaveLength(1); + expect(commands[0]).toContain('--only-targets'); + expect(commands[0]).toContain('/tmp/test.xcresult'); + }); + }); + describe('Command Generation', () => { it('should use --only-targets when showFiles is false', async () => { const commands: string[][] = []; const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify(sampleTargets), - onExecute: (command) => { commands.push(command); }, + onExecute: (command) => { + commands.push(command); + }, }); - await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); + await get_coverage_reportLogic( + { xcresultPath: '/tmp/test.xcresult', showFiles: false }, + { executor: mockExecutor, fileSystem: mockFileSystem }, + ); expect(commands).toHaveLength(1); expect(commands[0]).toContain('--only-targets'); @@ -77,10 +153,15 @@ describe('get_coverage_report', () => { const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify(sampleTargetsWithFiles), - onExecute: (command) => { commands.push(command); }, + onExecute: (command) => { + commands.push(command); + }, }); - await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor, mockFileSystem); + await get_coverage_reportLogic( + { xcresultPath: '/tmp/test.xcresult', showFiles: true }, + { executor: mockExecutor, fileSystem: mockFileSystem }, + ); expect(commands).toHaveLength(1); expect(commands[0]).not.toContain('--only-targets'); @@ -94,7 +175,10 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); + const result = await get_coverage_reportLogic( + { xcresultPath: '/tmp/test.xcresult', showFiles: false }, + { executor: mockExecutor, fileSystem: mockFileSystem }, + ); expect(result.isError).toBeUndefined(); expect(result.content).toHaveLength(1); @@ -102,7 +186,6 @@ describe('get_coverage_report', () => { expect(text).toContain('Code Coverage Report'); expect(text).toContain('Overall: 24.7%'); expect(text).toContain('180/730 lines'); - // Should be sorted ascending: Core (10%), MyApp (50%), Tests (100%) const coreIdx = text.indexOf('Core'); const appIdx = text.indexOf('MyApp.app'); const testIdx = text.indexOf('MyAppTests.xctest'); @@ -116,7 +199,10 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); + const result = await get_coverage_reportLogic( + { xcresultPath: '/tmp/test.xcresult', showFiles: false }, + { executor: mockExecutor, fileSystem: mockFileSystem }, + ); expect(result.nextStepParams).toEqual({ get_file_coverage: { xcresultPath: '/tmp/test.xcresult' }, @@ -130,7 +216,10 @@ describe('get_coverage_report', () => { output: JSON.stringify(nestedData), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); + const result = await 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 : ''; @@ -146,7 +235,10 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'MyApp', showFiles: false }, mockExecutor, mockFileSystem); + const result = await 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 : ''; @@ -161,7 +253,10 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'core', showFiles: false }, mockExecutor, mockFileSystem); + const result = await 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 : ''; @@ -174,7 +269,10 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargets), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', target: 'NonExistent', showFiles: false }, mockExecutor, mockFileSystem); + const result = await 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 : ''; @@ -189,7 +287,10 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargetsWithFiles), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor, mockFileSystem); + const result = await 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 : ''; @@ -205,10 +306,12 @@ describe('get_coverage_report', () => { output: JSON.stringify(sampleTargetsWithFiles), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: true }, mockExecutor, mockFileSystem); + const result = await get_coverage_reportLogic( + { xcresultPath: '/tmp/test.xcresult', showFiles: true }, + { executor: mockExecutor, fileSystem: mockFileSystem }, + ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; - // Under MyApp.app: AppDelegate (20%) before ViewModel (60%) const appDelegateIdx = text.indexOf('AppDelegate.swift'); const viewModelIdx = text.indexOf('ViewModel.swift'); expect(appDelegateIdx).toBeLessThan(viewModelIdx); @@ -220,7 +323,10 @@ describe('get_coverage_report', () => { const missingFs = createMockFileSystemExecutor({ existsSync: () => false }); const mockExecutor = createMockExecutor({ success: true, output: '{}' }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/missing.xcresult', showFiles: false }, mockExecutor, missingFs); + const result = await 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 : ''; @@ -234,7 +340,10 @@ describe('get_coverage_report', () => { error: 'Failed to load result bundle', }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/bad.xcresult', showFiles: false }, mockExecutor, mockFileSystem); + const result = await 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 : ''; @@ -248,7 +357,10 @@ describe('get_coverage_report', () => { output: 'not valid json', }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); + const result = await 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 : ''; @@ -261,7 +373,10 @@ describe('get_coverage_report', () => { output: JSON.stringify({ unexpected: 'format' }), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); + const result = await 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 : ''; @@ -274,7 +389,10 @@ describe('get_coverage_report', () => { output: JSON.stringify([]), }); - const result = await get_coverage_reportLogic({ xcresultPath: '/tmp/test.xcresult', showFiles: false }, mockExecutor, mockFileSystem); + const result = await 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 : ''; 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 ebd572b2..611bb9f7 100644 --- a/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts +++ b/src/mcp/tools/coverage/__tests__/get_file_coverage.test.ts @@ -3,22 +3,47 @@ * Covers happy-path, showLines, uncovered line parsing, and failure paths */ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect } from 'vitest'; import { createMockExecutor, createMockCommandResponse, - createCommandMatchingMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; +import { + __setTestCommandExecutorOverride, + __setTestFileSystemExecutorOverride, + __clearTestExecutorOverrides, +} from '../../../../utils/execution/index.ts'; import { schema, handler, get_file_coverageLogic } from '../get_file_coverage.ts'; const sampleFunctionsJson = [ { file: '/src/MyApp/ViewModel.swift', functions: [ - { name: 'init()', coveredLines: 5, executableLines: 5, executionCount: 3, lineCoverage: 1.0, lineNumber: 10 }, - { name: 'loadData()', coveredLines: 8, executableLines: 12, executionCount: 2, lineCoverage: 0.667, lineNumber: 20 }, - { name: 'reset()', coveredLines: 0, executableLines: 4, executionCount: 0, lineCoverage: 0, lineNumber: 40 }, + { + name: 'init()', + coveredLines: 5, + executableLines: 5, + executionCount: 3, + lineCoverage: 1.0, + lineNumber: 10, + }, + { + name: 'loadData()', + coveredLines: 8, + executableLines: 12, + executionCount: 2, + lineCoverage: 0.667, + lineNumber: 20, + }, + { + name: 'reset()', + coveredLines: 0, + executableLines: 4, + executionCount: 0, + lineCoverage: 0, + lineNumber: 40, + }, ], }, ]; @@ -38,6 +63,10 @@ const sampleArchiveOutput = [ const mockFileSystem = createMockFileSystemExecutor({ existsSync: () => true }); +afterEach(() => { + __clearTestExecutorOverrides(); +}); + describe('get_file_coverage', () => { describe('Export Validation', () => { it('should export get_file_coverageLogic function', () => { @@ -55,26 +84,77 @@ describe('get_file_coverage', () => { }); }); + describe('Handler DI', () => { + it('should use injected fileSystem from handler context', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + }); + const missingFs = createMockFileSystemExecutor({ existsSync: () => false }); + + __setTestCommandExecutorOverride(mockExecutor); + __setTestFileSystemExecutorOverride(missingFs); + + const result = await handler({ + xcresultPath: '/tmp/missing.xcresult', + file: 'ViewModel.swift', + showLines: false, + }); + + expect(result.isError).toBe(true); + const text = result.content[0].type === 'text' ? result.content[0].text : ''; + expect(text).toContain('File not found'); + }); + + it('should use injected command executor on handler happy path', async () => { + const commands: string[][] = []; + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + onExecute: (command) => { + commands.push(command); + }, + }); + const existingFs = createMockFileSystemExecutor({ existsSync: () => true }); + + __setTestCommandExecutorOverride(mockExecutor); + __setTestFileSystemExecutorOverride(existingFs); + + const result = await handler({ xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift' }); + + expect(result.isError).toBeUndefined(); + expect(commands).toHaveLength(1); + expect(commands[0]).toContain('--functions-for-file'); + expect(commands[0]).toContain('/tmp/test.xcresult'); + }); + }); + describe('Command Generation', () => { it('should generate correct functions-for-file command', async () => { const commands: string[][] = []; const mockExecutor = createMockExecutor({ success: true, output: JSON.stringify(sampleFunctionsJson), - onExecute: (command) => { commands.push(command); }, + onExecute: (command) => { + commands.push(command); + }, }); await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); expect(commands).toHaveLength(1); expect(commands[0]).toEqual([ - 'xcrun', 'xccov', 'view', '--report', - '--functions-for-file', 'ViewModel.swift', - '--json', '/tmp/test.xcresult', + 'xcrun', + 'xccov', + 'view', + '--report', + '--functions-for-file', + 'ViewModel.swift', + '--json', + '/tmp/test.xcresult', ]); }); @@ -91,21 +171,28 @@ describe('get_file_coverage', () => { commands.push(command); callCount++; if (callCount === 1) { - return createMockCommandResponse({ success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }); + return createMockCommandResponse({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + exitCode: 0, + }); } return createMockCommandResponse({ success: true, output: sampleArchiveOutput, exitCode: 0 }); }; await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); expect(commands).toHaveLength(2); expect(commands[1]).toEqual([ - 'xcrun', 'xccov', 'view', '--archive', - '--file', '/src/MyApp/ViewModel.swift', + 'xcrun', + 'xccov', + 'view', + '--archive', + '--file', + '/src/MyApp/ViewModel.swift', '/tmp/test.xcresult', ]); }); @@ -120,13 +207,11 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); expect(result.isError).toBeUndefined(); const text = result.content[0].type === 'text' ? result.content[0].text : ''; - // File summary computed from functions: 13/21 lines expect(text).toContain('File: /src/MyApp/ViewModel.swift'); expect(text).toContain('Coverage: 61.9%'); expect(text).toContain('13/21 lines'); @@ -140,8 +225,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -157,8 +241,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -177,8 +260,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -194,8 +276,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); expect(result.nextStepParams).toEqual({ @@ -217,7 +298,14 @@ describe('get_file_coverage', () => { executableLines: 20, lineCoverage: 0.5, functions: [ - { name: 'save()', coveredLines: 10, executableLines: 20, executionCount: 5, lineCoverage: 0.5, lineNumber: 1 }, + { + name: 'save()', + coveredLines: 10, + executableLines: 20, + executionCount: 5, + lineCoverage: 0.5, + lineNumber: 1, + }, ], }, ], @@ -232,8 +320,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Model.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); expect(result.isError).toBeUndefined(); @@ -255,15 +342,18 @@ describe('get_file_coverage', () => { ) => { callCount++; if (callCount === 1) { - return createMockCommandResponse({ success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }); + return createMockCommandResponse({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + exitCode: 0, + }); } return createMockCommandResponse({ success: true, output: sampleArchiveOutput, exitCode: 0 }); }; const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -284,15 +374,18 @@ describe('get_file_coverage', () => { ) => { callCount++; if (callCount === 1) { - return createMockCommandResponse({ success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }); + return createMockCommandResponse({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + exitCode: 0, + }); } return createMockCommandResponse({ success: true, output: allCoveredArchive, exitCode: 0 }); }; const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); const text = result.content[0].type === 'text' ? result.content[0].text : ''; @@ -310,15 +403,23 @@ describe('get_file_coverage', () => { ) => { callCount++; if (callCount === 1) { - return createMockCommandResponse({ success: true, output: JSON.stringify(sampleFunctionsJson), exitCode: 0 }); + return createMockCommandResponse({ + success: true, + output: JSON.stringify(sampleFunctionsJson), + exitCode: 0, + }); } - return createMockCommandResponse({ success: false, output: '', error: 'archive error', exitCode: 1 }); + return createMockCommandResponse({ + success: false, + output: '', + error: 'archive error', + exitCode: 1, + }); }; const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'ViewModel.swift', showLines: true }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); expect(result.isError).toBeUndefined(); @@ -334,8 +435,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/missing.xcresult', file: 'Foo.swift', showLines: false }, - mockExecutor, - missingFs, + { executor: mockExecutor, fileSystem: missingFs }, ); expect(result.isError).toBe(true); @@ -352,8 +452,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/bad.xcresult', file: 'Foo.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); expect(result.isError).toBe(true); @@ -370,8 +469,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Foo.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); expect(result.isError).toBe(true); @@ -387,8 +485,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Missing.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); expect(result.isError).toBe(true); @@ -404,8 +501,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Foo.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); expect(result.isError).toBe(true); @@ -422,8 +518,7 @@ describe('get_file_coverage', () => { const result = await get_file_coverageLogic( { xcresultPath: '/tmp/test.xcresult', file: 'Empty.swift', showLines: false }, - mockExecutor, - mockFileSystem, + { executor: mockExecutor, fileSystem: mockFileSystem }, ); expect(result.isError).toBeUndefined(); diff --git a/src/mcp/tools/coverage/get_coverage_report.ts b/src/mcp/tools/coverage/get_coverage_report.ts index e8c145c9..3ab48783 100644 --- a/src/mcp/tools/coverage/get_coverage_report.ts +++ b/src/mcp/tools/coverage/get_coverage_report.ts @@ -10,8 +10,8 @@ import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { validateFileExists } from '../../../utils/validation/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; -import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/execution/index.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; const getCoverageReportSchema = z.object({ xcresultPath: z.string().describe('Path to the .xcresult bundle'), @@ -52,14 +52,18 @@ function isValidCoverageTarget(value: unknown): value is CoverageTarget { ); } +type GetCoverageReportContext = { + executor: CommandExecutor; + fileSystem: FileSystemExecutor; +}; + export async function get_coverage_reportLogic( params: GetCoverageReportParams, - executor: CommandExecutor, - fileSystem?: FileSystemExecutor, + context: GetCoverageReportContext, ): Promise { const { xcresultPath, target, showFiles } = params; - const fileExistsValidation = validateFileExists(xcresultPath, fileSystem); + const fileExistsValidation = validateFileExists(xcresultPath, context.fileSystem); if (!fileExistsValidation.isValid) { return fileExistsValidation.errorResponse!; } @@ -72,7 +76,7 @@ export async function get_coverage_reportLogic( } cmd.push('--json', xcresultPath); - const result = await executor(cmd, 'Get Coverage Report', false, undefined); + const result = await context.executor(cmd, 'Get Coverage Report', false, undefined); if (!result.success) { return { @@ -197,8 +201,11 @@ export async function get_coverage_reportLogic( export const schema = getCoverageReportSchema.shape; -export const handler = createTypedTool( +export const handler = createTypedToolWithContext( getCoverageReportSchema, get_coverage_reportLogic, - getDefaultCommandExecutor, + () => ({ + executor: getDefaultCommandExecutor(), + fileSystem: getDefaultFileSystemExecutor(), + }), ); diff --git a/src/mcp/tools/coverage/get_file_coverage.ts b/src/mcp/tools/coverage/get_file_coverage.ts index f9928923..03d62776 100644 --- a/src/mcp/tools/coverage/get_file_coverage.ts +++ b/src/mcp/tools/coverage/get_file_coverage.ts @@ -10,8 +10,8 @@ import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { validateFileExists } from '../../../utils/validation/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; -import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/execution/index.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; const getFileCoverageSchema = z.object({ xcresultPath: z.string().describe('Path to the .xcresult bundle'), @@ -64,14 +64,18 @@ function normalizeFileEntry(raw: RawFileEntry): FileFunctionCoverage { return { filePath, coveredLines, executableLines, lineCoverage, functions }; } +type GetFileCoverageContext = { + executor: CommandExecutor; + fileSystem: FileSystemExecutor; +}; + export async function get_file_coverageLogic( params: GetFileCoverageParams, - executor: CommandExecutor, - fileSystem?: FileSystemExecutor, + context: GetFileCoverageContext, ): Promise { const { xcresultPath, file, showLines } = params; - const fileExistsValidation = validateFileExists(xcresultPath, fileSystem); + const fileExistsValidation = validateFileExists(xcresultPath, context.fileSystem); if (!fileExistsValidation.isValid) { return fileExistsValidation.errorResponse!; } @@ -79,7 +83,7 @@ export async function get_file_coverageLogic( log('info', `Getting file coverage for "${file}" from: ${xcresultPath}`); // Get function-level coverage - const funcResult = await executor( + const funcResult = await context.executor( ['xcrun', 'xccov', 'view', '--report', '--functions-for-file', file, '--json', xcresultPath], 'Get File Function Coverage', false, @@ -182,7 +186,7 @@ export async function get_file_coverageLogic( // Optionally get line-by-line coverage from the archive if (showLines) { const filePath = fileEntries[0].filePath !== 'unknown' ? fileEntries[0].filePath : file; - const archiveResult = await executor( + const archiveResult = await context.executor( ['xcrun', 'xccov', 'view', '--archive', '--file', filePath, xcresultPath], 'Get File Line Coverage', false, @@ -266,8 +270,11 @@ function parseUncoveredLines(output: string): LineRange[] { export const schema = getFileCoverageSchema.shape; -export const handler = createTypedTool( +export const handler = createTypedToolWithContext( getFileCoverageSchema, get_file_coverageLogic, - getDefaultCommandExecutor, + () => ({ + executor: getDefaultCommandExecutor(), + fileSystem: getDefaultFileSystemExecutor(), + }), ); From 5bb138ede9e626bcad4d924d59e6be2531bee555 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 3 Mar 2026 10:57:56 +0000 Subject: [PATCH 14/14] Validate target objects before accessing properties in get_file_coverage Filter out null/non-object elements in the targets array before accessing properties, preventing TypeError on malformed xccov output. Consistent with the isValidCoverageTarget guard in get_coverage_report. --- src/mcp/tools/coverage/get_file_coverage.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/mcp/tools/coverage/get_file_coverage.ts b/src/mcp/tools/coverage/get_file_coverage.ts index 03d62776..f1f719cf 100644 --- a/src/mcp/tools/coverage/get_file_coverage.ts +++ b/src/mcp/tools/coverage/get_file_coverage.ts @@ -130,10 +130,12 @@ export async function get_file_coverageLogic( 'targets' in data && Array.isArray((data as { targets: unknown }).targets) ) { - const targets = (data as { targets: { files?: RawFileEntry[] }[] }).targets; + const targets = (data as { targets: unknown[] }).targets; for (const t of targets) { - if (t.files) { - fileEntries.push(...t.files.map(normalizeFileEntry)); + if (typeof t !== 'object' || t === null) continue; + const target = t as { files?: RawFileEntry[] }; + if (target.files) { + fileEntries.push(...target.files.map(normalizeFileEntry)); } } }