diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index 8716666d..f348cc0b 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/__tests__/text-render-parity.test.ts b/src/rendering/__tests__/text-render-parity.test.ts index 38274a6f..ff793954 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/rendering/render.ts b/src/rendering/render.ts index 669bca7b..88a91400 100644 --- a/src/rendering/render.ts +++ b/src/rendering/render.ts @@ -54,17 +54,20 @@ 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, }), }); } 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), diff --git a/src/types/pipeline-events.ts b/src/types/pipeline-events.ts index 5dbc424d..53a033ea 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/__tests__/xcodebuild-event-parser.test.ts b/src/utils/__tests__/xcodebuild-event-parser.test.ts index 4ed3db74..91bffdac 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 2a56f4aa..70c03a5b 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/config-store.ts b/src/utils/config-store.ts index f0bc26ae..f590d491 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -265,6 +265,7 @@ function readEnvSessionDefaults(env: NodeJS.ProcessEnv): Partial { @@ -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(''); + }); }); diff --git a/src/utils/renderers/cli-text-renderer.ts b/src/utils/renderers/cli-text-renderer.ts index 5a94eaee..15506e60 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 { @@ -45,18 +47,21 @@ interface CliTextProcessorOptions { interactive: boolean; sink: CliTextSink; suppressWarnings: boolean; + showTestResults: boolean; } interface CliTextRendererOptions { interactive: boolean; suppressWarnings?: boolean; + showTestResults?: boolean; } function createCliTextProcessor(options: CliTextProcessorOptions): PipelineRenderer { - const { interactive, sink, suppressWarnings } = options; + const { interactive, sink, suppressWarnings, showTestResults } = options; 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 +193,13 @@ function createCliTextProcessor(options: CliTextProcessorOptions): PipelineRende break; } + case 'test-case-result': { + if (showTestResults) { + collectedTestCaseResults.push(event); + } + break; + } + case 'summary': { const diagOpts = { baseDir: diagnosticBaseDir ?? undefined }; const diagnosticSections: string[] = []; @@ -221,6 +233,14 @@ function createCliTextProcessor(options: CliTextProcessorOptions): PipelineRende flushPendingTransientRuntimeLine(); } + if (showTestResults && collectedTestCaseResults.length > 0) { + const testResultsBlock = formatTestCaseResults(collectedTestCaseResults); + if (testResultsBlock) { + writeSection(testResultsBlock); + } + collectedTestCaseResults.length = 0; + } + writeSection(formatSummaryEvent(event)); lastVisibleEventType = 'summary'; lastStatusLineLevel = null; @@ -255,6 +275,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(); @@ -274,12 +295,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/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts index e1e3a8c2..572af2d6 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'); +} diff --git a/src/utils/session-defaults-schema.ts b/src/utils/session-defaults-schema.ts index 2e99735f..f9e2a185 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 2d1cdf1b..d639c49b 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; diff --git a/src/utils/swift-testing-event-parser.ts b/src/utils/swift-testing-event-parser.ts index aca7d9cb..b24de86c 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 51e763ae..38ec39bc 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(); } diff --git a/src/utils/xcodebuild-run-state.ts b/src/utils/xcodebuild-run-state.ts index d8adff2e..541af9fb 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], }; },