From b4e5cfeb3169e04774405a3415913c3909aa79ae Mon Sep 17 00:00:00 2001 From: Cody Vandermyn Date: Tue, 14 Apr 2026 07:41:42 -0700 Subject: [PATCH 1/7] feat(events): add TestCaseResultEvent for per-test timing Emit a test-case-result event for every individual test case (passed, failed, skipped) with its duration. Previously only failures carried per-test timing; passing tests were reduced to aggregate counters. --- src/types/pipeline-events.ts | 10 ++++++++++ src/utils/swift-testing-event-parser.ts | 23 +++++++++++++++++++++++ src/utils/xcodebuild-event-parser.ts | 11 +++++++++++ 3 files changed, 44 insertions(+) diff --git a/src/types/pipeline-events.ts b/src/types/pipeline-events.ts index 5dbc424d5..53a033ea6 100644 --- a/src/types/pipeline-events.ts +++ b/src/types/pipeline-events.ts @@ -127,6 +127,15 @@ export interface TestProgressEvent extends BaseEvent { skipped: number; } +export interface TestCaseResultEvent extends BaseEvent { + type: 'test-case-result'; + operation: 'TEST'; + suite?: string; + test: string; + status: 'passed' | 'failed' | 'skipped'; + durationMs?: number; +} + export interface TestFailureEvent extends BaseEvent { type: 'test-failure'; operation: 'TEST'; @@ -158,6 +167,7 @@ export type BuildTestPipelineEvent = | CompilerErrorEvent | TestDiscoveryEvent | TestProgressEvent + | TestCaseResultEvent | TestFailureEvent; export type PipelineEvent = CommonPipelineEvent | BuildTestPipelineEvent; diff --git a/src/utils/swift-testing-event-parser.ts b/src/utils/swift-testing-event-parser.ts index aca7d9cbe..b24de86cb 100644 --- a/src/utils/swift-testing-event-parser.ts +++ b/src/utils/swift-testing-event-parser.ts @@ -71,6 +71,26 @@ export function createSwiftTestingEventParser( }); } + function emitTestCaseResult(testCase: { + suiteName?: string; + testName?: string; + status: string; + durationText?: string; + }): void { + if (!testCase.testName) { + return; + } + onEvent({ + type: 'test-case-result', + timestamp: now(), + operation: 'TEST', + suite: testCase.suiteName, + test: testCase.testName, + status: testCase.status as 'passed' | 'failed' | 'skipped', + durationMs: parseDurationMs(testCase.durationText), + }); + } + function processLine(rawLine: string): void { const line = rawLine.trim(); if (!line) { @@ -103,6 +123,7 @@ export function createSwiftTestingEventParser( const increment = stResult.caseCount ?? 1; completedCount += increment; failedCount += increment; + emitTestCaseResult(stResult); emitTestProgress(); return; } @@ -131,6 +152,7 @@ export function createSwiftTestingEventParser( if (stResult.status === 'skipped') { skippedCount += increment; } + emitTestCaseResult(stResult); emitTestProgress(); return; } @@ -155,6 +177,7 @@ export function createSwiftTestingEventParser( if (xcTestCase.status === 'skipped') { skippedCount += xcIncrement; } + emitTestCaseResult(xcTestCase); emitTestProgress(); return; } diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts index 51e763ae7..38ec39bc6 100644 --- a/src/utils/xcodebuild-event-parser.ts +++ b/src/utils/xcodebuild-event-parser.ts @@ -247,6 +247,17 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb if (testCase.status === 'skipped') { skippedCount += increment; } + if (operation === 'TEST' && testCase.testName) { + onEvent({ + type: 'test-case-result', + timestamp: now(), + operation: 'TEST', + suite: testCase.suiteName, + test: testCase.testName, + status: testCase.status as 'passed' | 'failed' | 'skipped', + durationMs: parseDurationMs(testCase.durationText), + }); + } emitTestProgress(); } From de90b4858a1650495a0d890a55dca013c9198327 Mon Sep 17 00:00:00 2001 From: Cody Vandermyn Date: Tue, 14 Apr 2026 07:41:51 -0700 Subject: [PATCH 2/7] feat(state): collect test case results in run state Track TestCaseResultEvent in XcodebuildRunState so downstream consumers can access per-test timing data. --- src/utils/xcodebuild-run-state.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/utils/xcodebuild-run-state.ts b/src/utils/xcodebuild-run-state.ts index d8adff2e0..541af9fbf 100644 --- a/src/utils/xcodebuild-run-state.ts +++ b/src/utils/xcodebuild-run-state.ts @@ -5,6 +5,7 @@ import type { BuildStageEvent, CompilerWarningEvent, CompilerErrorEvent, + TestCaseResultEvent, TestFailureEvent, } from '../types/pipeline-events.ts'; import { STAGE_RANK } from '../types/pipeline-events.ts'; @@ -15,6 +16,7 @@ export interface XcodebuildRunState { milestones: BuildStageEvent[]; warnings: CompilerWarningEvent[]; errors: CompilerErrorEvent[]; + testCaseResults: TestCaseResultEvent[]; testFailures: TestFailureEvent[]; completedTests: number; failedTests: number; @@ -83,6 +85,7 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu milestones: [], warnings: [], errors: [], + testCaseResults: [], testFailures: [], completedTests: 0, failedTests: 0, @@ -149,6 +152,12 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu break; } + case 'test-case-result': { + state.testCaseResults.push(event); + accept(event); + break; + } + case 'test-progress': { state.completedTests = event.completed; state.failedTests = event.failed; @@ -235,6 +244,7 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu milestones: [...state.milestones], warnings: [...state.warnings], errors: [...state.errors], + testCaseResults: [...state.testCaseResults], testFailures: [...state.testFailures], }; }, @@ -246,6 +256,7 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu milestones: [...state.milestones], warnings: [...state.warnings], errors: [...state.errors], + testCaseResults: [...state.testCaseResults], testFailures: [...state.testFailures], }; }, From 43903ca083177221fee4a75ce5ead1da69160fd3 Mon Sep 17 00:00:00 2001 From: Cody Vandermyn Date: Tue, 14 Apr 2026 07:42:22 -0700 Subject: [PATCH 3/7] feat(render): display per-test timing in summary output Render a Test Results section before the summary line showing each test case with its status icon, suite/test name, and duration. --- src/utils/renderers/cli-text-renderer.ts | 16 ++++++++++++++++ src/utils/renderers/event-formatting.ts | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/utils/renderers/cli-text-renderer.ts b/src/utils/renderers/cli-text-renderer.ts index 5a94eaee7..b04b11364 100644 --- a/src/utils/renderers/cli-text-renderer.ts +++ b/src/utils/renderers/cli-text-renderer.ts @@ -3,6 +3,7 @@ import type { CompilerWarningEvent, PipelineEvent, StatusLineEvent, + TestCaseResultEvent, TestFailureEvent, } from '../../types/pipeline-events.ts'; import { createCliProgressReporter } from '../cli-progress-reporter.ts'; @@ -25,6 +26,7 @@ import { formatSummaryEvent, formatNextStepsEvent, formatTestDiscoveryEvent, + formatTestCaseResults, } from './event-formatting.ts'; function formatCliTextBlock(text: string): string { @@ -57,6 +59,7 @@ function createCliTextProcessor(options: CliTextProcessorOptions): PipelineRende const groupedCompilerErrors: CompilerErrorEvent[] = []; const groupedWarnings: CompilerWarningEvent[] = []; const groupedTestFailures: TestFailureEvent[] = []; + const collectedTestCaseResults: TestCaseResultEvent[] = []; let pendingTransientRuntimeLine: string | null = null; let diagnosticBaseDir: string | null = null; let hasDurableRuntimeContent = false; @@ -188,6 +191,11 @@ function createCliTextProcessor(options: CliTextProcessorOptions): PipelineRende break; } + case 'test-case-result': { + collectedTestCaseResults.push(event); + break; + } + case 'summary': { const diagOpts = { baseDir: diagnosticBaseDir ?? undefined }; const diagnosticSections: string[] = []; @@ -221,6 +229,14 @@ function createCliTextProcessor(options: CliTextProcessorOptions): PipelineRende flushPendingTransientRuntimeLine(); } + if (collectedTestCaseResults.length > 0) { + const testResultsBlock = formatTestCaseResults(collectedTestCaseResults); + if (testResultsBlock) { + writeSection(testResultsBlock); + } + collectedTestCaseResults.length = 0; + } + writeSection(formatSummaryEvent(event)); lastVisibleEventType = 'summary'; lastStatusLineLevel = null; diff --git a/src/utils/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts index e1e3a8c2e..572af2d6e 100644 --- a/src/utils/renderers/event-formatting.ts +++ b/src/utils/renderers/event-formatting.ts @@ -12,6 +12,7 @@ import type { FileRefEvent, DetailTreeEvent, SummaryEvent, + TestCaseResultEvent, TestDiscoveryEvent, TestFailureEvent, NextStepsEvent, @@ -593,3 +594,24 @@ export function formatGroupedTestFailures( return lines.join('\n'); } + +export function formatTestCaseResults(results: TestCaseResultEvent[]): string { + if (results.length === 0) { + return ''; + } + + const statusIcon: Record = { + passed: '\u{2705}', + failed: '\u{274C}', + skipped: '\u{23ED}\u{FE0F}', + }; + + const lines: string[] = ['Test Results:']; + for (const r of results) { + const icon = statusIcon[r.status] ?? '?'; + const duration = r.durationMs !== undefined ? ` (${(r.durationMs / 1000).toFixed(3)}s)` : ''; + const name = r.suite ? `${r.suite}/${r.test}` : r.test; + lines.push(` ${icon} ${name}${duration}`); + } + return lines.join('\n'); +} From 273ac1f0ceb580250cc84c2329e731b5f8a2d05a Mon Sep 17 00:00:00 2001 From: Cody Vandermyn Date: Tue, 14 Apr 2026 14:53:43 -0700 Subject: [PATCH 4/7] feat(config): add showTestResults session default to gate per-test output Per-test timing is opt-in via the showTestResults session default or XCODEBUILDMCP_SHOW_TEST_RESULTS env var. When disabled (the default), only the aggregate summary line is shown. --- .../tools/session-management/session_set_defaults.ts | 1 + src/rendering/render.ts | 2 ++ src/utils/config-store.ts | 1 + src/utils/renderers/cli-text-renderer.ts | 10 +++++++--- src/utils/session-defaults-schema.ts | 5 +++++ src/utils/session-store.ts | 1 + 6 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index 8716666d1..f348cc0b9 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -58,6 +58,7 @@ const PARAM_LABEL_MAP: Record = { useLatestOS: 'Use Latest OS', arch: 'Architecture', suppressWarnings: 'Suppress Warnings', + showTestResults: 'Show Test Results', derivedDataPath: 'Derived Data Path', preferXcodebuild: 'Prefer xcodebuild', platform: 'Platform', diff --git a/src/rendering/render.ts b/src/rendering/render.ts index 669bca7b3..de60c47fc 100644 --- a/src/rendering/render.ts +++ b/src/rendering/render.ts @@ -54,11 +54,13 @@ function createBaseRenderSession(hooks: RenderSessionHooks): RenderSession { function createTextRenderSession(): RenderSession { const suppressWarnings = sessionStore.get('suppressWarnings'); + const showTestResults = sessionStore.get('showTestResults'); return createBaseRenderSession({ finalize: (events) => renderCliTextTranscript(events, { suppressWarnings: suppressWarnings ?? false, + showTestResults: showTestResults ?? false, }), }); } diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index f0bc26ae7..f590d4915 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -265,6 +265,7 @@ function readEnvSessionDefaults(env: NodeJS.ProcessEnv): Partial 0) { + if (showTestResults && collectedTestCaseResults.length > 0) { const testResultsBlock = formatTestCaseResults(collectedTestCaseResults); if (testResultsBlock) { writeSection(testResultsBlock); @@ -271,6 +273,7 @@ export function createCliTextRenderer(options: CliTextRendererOptions): Pipeline return createCliTextProcessor({ interactive: options.interactive, suppressWarnings: options.suppressWarnings ?? false, + showTestResults: options.showTestResults ?? false, sink: { clearTransient(): void { reporter.clear(); @@ -290,12 +293,13 @@ export function createCliTextRenderer(options: CliTextRendererOptions): Pipeline export function renderCliTextTranscript( events: readonly PipelineEvent[], - options: { suppressWarnings?: boolean } = {}, + options: { suppressWarnings?: boolean; showTestResults?: boolean } = {}, ): string { let output = ''; const renderer = createCliTextProcessor({ interactive: false, suppressWarnings: options.suppressWarnings ?? false, + showTestResults: options.showTestResults ?? false, sink: { clearTransient(): void {}, updateTransient(): void {}, diff --git a/src/utils/session-defaults-schema.ts b/src/utils/session-defaults-schema.ts index 2e99735f5..f9e2a1854 100644 --- a/src/utils/session-defaults-schema.ts +++ b/src/utils/session-defaults-schema.ts @@ -14,6 +14,7 @@ export const sessionDefaultKeys = [ 'useLatestOS', 'arch', 'suppressWarnings', + 'showTestResults', 'derivedDataPath', 'preferXcodebuild', 'platform', @@ -40,6 +41,10 @@ export const sessionDefaultsSchema = z.object({ useLatestOS: z.boolean().optional(), arch: z.enum(['arm64', 'x86_64']).optional(), suppressWarnings: z.boolean().optional(), + showTestResults: z + .boolean() + .optional() + .describe('Show per-test timing breakdown in test output.'), derivedDataPath: nonEmptyString .optional() .describe('Default DerivedData path for Xcode build/test/clean tools.'), diff --git a/src/utils/session-store.ts b/src/utils/session-store.ts index 2d1cdf1be..d639c49bb 100644 --- a/src/utils/session-store.ts +++ b/src/utils/session-store.ts @@ -16,6 +16,7 @@ export type SessionDefaults = { useLatestOS?: boolean; arch?: 'arm64' | 'x86_64'; suppressWarnings?: boolean; + showTestResults?: boolean; derivedDataPath?: string; preferXcodebuild?: boolean; platform?: string; From b19b6ab94e94c35be13ed6cfae980ac6ce60e723 Mon Sep 17 00:00:00 2001 From: Cody Vandermyn Date: Tue, 14 Apr 2026 14:54:11 -0700 Subject: [PATCH 5/7] test: add tests for per-test timing events and showTestResults flag --- .../__tests__/text-render-parity.test.ts | 34 +++++++++++++++++++ .../__tests__/xcodebuild-event-parser.test.ts | 30 ++++++++++++++++ .../__tests__/xcodebuild-run-state.test.ts | 28 +++++++++++++++ .../__tests__/event-formatting.test.ts | 31 +++++++++++++++++ 4 files changed, 123 insertions(+) diff --git a/src/rendering/__tests__/text-render-parity.test.ts b/src/rendering/__tests__/text-render-parity.test.ts index 38274a6f5..ff7939544 100644 --- a/src/rendering/__tests__/text-render-parity.test.ts +++ b/src/rendering/__tests__/text-render-parity.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { PipelineEvent } from '../../types/pipeline-events.ts'; import { renderEvents } from '../render.ts'; import { createCliTextRenderer } from '../../utils/renderers/cli-text-renderer.ts'; +import { renderCliTextTranscript } from '../../utils/renderers/cli-text-renderer.ts'; function captureCliText(events: readonly PipelineEvent[]): string { const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -168,4 +169,37 @@ describe('text render parity', () => { expect(output).toContain('xcodebuildmcp macos get-app-path --scheme "MCPTest"'); expect(output).not.toContain('get_mac_app_path({'); }); + + it('omits per-test results by default and includes them when showTestResults is true', () => { + const events: PipelineEvent[] = [ + { + type: 'test-case-result', + timestamp: '2026-04-14T00:00:00.000Z', + operation: 'TEST', + suite: 'Suite', + test: 'testA', + status: 'passed', + durationMs: 100, + }, + { + type: 'summary', + timestamp: '2026-04-14T00:00:01.000Z', + operation: 'TEST', + status: 'SUCCEEDED', + totalTests: 1, + passedTests: 1, + skippedTests: 0, + durationMs: 100, + }, + ]; + + const withoutFlag = renderCliTextTranscript(events); + expect(withoutFlag).not.toContain('Test Results:'); + expect(withoutFlag).toContain('1 test passed'); + + const withFlag = renderCliTextTranscript(events, { showTestResults: true }); + expect(withFlag).toContain('Test Results:'); + expect(withFlag).toContain('Suite/testA (0.100s)'); + expect(withFlag).toContain('1 test passed'); + }); }); diff --git a/src/utils/__tests__/xcodebuild-event-parser.test.ts b/src/utils/__tests__/xcodebuild-event-parser.test.ts index 4ed3db74b..91bffdac6 100644 --- a/src/utils/__tests__/xcodebuild-event-parser.test.ts +++ b/src/utils/__tests__/xcodebuild-event-parser.test.ts @@ -304,4 +304,34 @@ describe('xcodebuild-event-parser', () => { const statusEvents = events.filter((e) => e.type === 'build-stage'); expect(statusEvents.length).toBeLessThanOrEqual(1); }); + + it('emits test-case-result events with per-test timing for all statuses', () => { + const events = collectEvents('TEST', [ + { source: 'stdout', text: "Test Case '-[Suite testA]' passed (0.001 seconds)\n" }, + { source: 'stdout', text: "Test Case '-[Suite testB]' failed (0.002 seconds)\n" }, + { source: 'stdout', text: "Test Case '-[Suite testC]' skipped (0.000 seconds)\n" }, + ]); + + const results = events.filter((e) => e.type === 'test-case-result'); + expect(results).toHaveLength(3); + expect(results[0]).toMatchObject({ + type: 'test-case-result', + operation: 'TEST', + suite: 'Suite', + test: 'testA', + status: 'passed', + durationMs: 1, + }); + expect(results[1]).toMatchObject({ test: 'testB', status: 'failed', durationMs: 2 }); + expect(results[2]).toMatchObject({ test: 'testC', status: 'skipped', durationMs: 0 }); + }); + + it('does not emit test-case-result for BUILD operations', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: "Test Case '-[Suite testA]' passed (0.001 seconds)\n" }, + ]); + + const results = events.filter((e) => e.type === 'test-case-result'); + expect(results).toHaveLength(0); + }); }); diff --git a/src/utils/__tests__/xcodebuild-run-state.test.ts b/src/utils/__tests__/xcodebuild-run-state.test.ts index 2a56f4aa5..70c03a5b9 100644 --- a/src/utils/__tests__/xcodebuild-run-state.test.ts +++ b/src/utils/__tests__/xcodebuild-run-state.test.ts @@ -404,4 +404,32 @@ describe('xcodebuild-run-state', () => { expect(forwarded[0].type).toBe('header'); expect(forwarded[1].type).toBe('next-steps'); }); + + it('collects test-case-result events', () => { + const state = createXcodebuildRunState({ operation: 'TEST' }); + + state.push({ + type: 'test-case-result', + timestamp: ts(), + operation: 'TEST', + suite: 'Suite', + test: 'testA', + status: 'passed', + durationMs: 100, + }); + state.push({ + type: 'test-case-result', + timestamp: ts(), + operation: 'TEST', + suite: 'Suite', + test: 'testB', + status: 'failed', + durationMs: 200, + }); + + const snap = state.snapshot(); + expect(snap.testCaseResults).toHaveLength(2); + expect(snap.testCaseResults[0]).toMatchObject({ test: 'testA', status: 'passed' }); + expect(snap.testCaseResults[1]).toMatchObject({ test: 'testB', status: 'failed' }); + }); }); diff --git a/src/utils/renderers/__tests__/event-formatting.test.ts b/src/utils/renderers/__tests__/event-formatting.test.ts index 211556c77..4d1400322 100644 --- a/src/utils/renderers/__tests__/event-formatting.test.ts +++ b/src/utils/renderers/__tests__/event-formatting.test.ts @@ -12,6 +12,7 @@ import { formatStatusLineEvent, formatDetailTreeEvent, formatTransientStatusLineEvent, + formatTestCaseResults, } from '../event-formatting.ts'; describe('event formatting', () => { @@ -277,4 +278,34 @@ describe('event formatting', () => { expect(rendered).toContain(' - XCTAssertEqual failed'); expect(rendered).toContain(' - Expected 4, got 5'); }); + + it('formats per-test case results with status icons and durations', () => { + const rendered = formatTestCaseResults([ + { + type: 'test-case-result', + timestamp: '', + operation: 'TEST', + suite: 'MathTests', + test: 'testAdd', + status: 'passed', + durationMs: 1234, + }, + { + type: 'test-case-result', + timestamp: '', + operation: 'TEST', + test: 'testOrphan', + status: 'skipped', + }, + ]); + + expect(rendered).toContain('Test Results:'); + expect(rendered).toContain('\u{2705} MathTests/testAdd (1.234s)'); + expect(rendered).toContain('\u{23ED}\u{FE0F} testOrphan'); + expect(rendered).not.toContain('testOrphan ('); + }); + + it('returns empty string for no test case results', () => { + expect(formatTestCaseResults([])).toBe(''); + }); }); From 3579e7c85a2c15e89061da7183daceb7b13827ce Mon Sep 17 00:00:00 2001 From: Cody Vandermyn Date: Wed, 15 Apr 2026 07:33:48 -0700 Subject: [PATCH 6/7] fix(render): pass showTestResults to interactive CLI renderer Fixes a bug where the interactive CLI text path did not read showTestResults from the session store, so per-test results were never shown even when the feature was enabled. --- src/rendering/render.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rendering/render.ts b/src/rendering/render.ts index de60c47fc..88a91400a 100644 --- a/src/rendering/render.ts +++ b/src/rendering/render.ts @@ -66,7 +66,8 @@ function createTextRenderSession(): RenderSession { } function createCliTextRenderSession(options: { interactive: boolean }): RenderSession { - const renderer = createCliTextRenderer(options); + const showTestResults = sessionStore.get('showTestResults'); + const renderer = createCliTextRenderer({ ...options, showTestResults: showTestResults ?? false }); return createBaseRenderSession({ onEmit: (event) => renderer.onEvent(event), From 9f82a8054191b174ab6b705376e445b291a466d5 Mon Sep 17 00:00:00 2001 From: Cody Vandermyn Date: Wed, 15 Apr 2026 07:52:46 -0700 Subject: [PATCH 7/7] fix(render): gate test-case-result collection on showTestResults flag Avoid accumulating test-case-result events when showTestResults is disabled, matching the existing suppressWarnings collection pattern. --- src/utils/renderers/cli-text-renderer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/renderers/cli-text-renderer.ts b/src/utils/renderers/cli-text-renderer.ts index ce27ae3f9..15506e606 100644 --- a/src/utils/renderers/cli-text-renderer.ts +++ b/src/utils/renderers/cli-text-renderer.ts @@ -194,7 +194,9 @@ function createCliTextProcessor(options: CliTextProcessorOptions): PipelineRende } case 'test-case-result': { - collectedTestCaseResults.push(event); + if (showTestResults) { + collectedTestCaseResults.push(event); + } break; }