Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/mcp/tools/session-management/session_set_defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const PARAM_LABEL_MAP: Record<string, string> = {
useLatestOS: 'Use Latest OS',
arch: 'Architecture',
suppressWarnings: 'Suppress Warnings',
showTestResults: 'Show Test Results',
derivedDataPath: 'Derived Data Path',
preferXcodebuild: 'Prefer xcodebuild',
platform: 'Platform',
Expand Down
34 changes: 34 additions & 0 deletions src/rendering/__tests__/text-render-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
});
});
5 changes: 4 additions & 1 deletion src/rendering/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
});
}
Comment thread
sentry[bot] marked this conversation as resolved.

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),
Expand Down
10 changes: 10 additions & 0 deletions src/types/pipeline-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -158,6 +167,7 @@ export type BuildTestPipelineEvent =
| CompilerErrorEvent
| TestDiscoveryEvent
| TestProgressEvent
| TestCaseResultEvent
| TestFailureEvent;

export type PipelineEvent = CommonPipelineEvent | BuildTestPipelineEvent;
Expand Down
30 changes: 30 additions & 0 deletions src/utils/__tests__/xcodebuild-event-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
28 changes: 28 additions & 0 deletions src/utils/__tests__/xcodebuild-run-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
1 change: 1 addition & 0 deletions src/utils/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ function readEnvSessionDefaults(env: NodeJS.ProcessEnv): Partial<SessionDefaults
setString('bundleId', env.XCODEBUILDMCP_BUNDLE_ID);
setBool('useLatestOS', env.XCODEBUILDMCP_USE_LATEST_OS);
setBool('suppressWarnings', env.XCODEBUILDMCP_SUPPRESS_WARNINGS);
setBool('showTestResults', env.XCODEBUILDMCP_SHOW_TEST_RESULTS);
setBool('preferXcodebuild', env.XCODEBUILDMCP_PREFER_XCODEBUILD);

const simulatorPlatform = env.XCODEBUILDMCP_SIMULATOR_PLATFORM;
Expand Down
31 changes: 31 additions & 0 deletions src/utils/renderers/__tests__/event-formatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
formatStatusLineEvent,
formatDetailTreeEvent,
formatTransientStatusLineEvent,
formatTestCaseResults,
} from '../event-formatting.ts';

describe('event formatting', () => {
Expand Down Expand Up @@ -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('');
});
});
26 changes: 24 additions & 2 deletions src/utils/renderers/cli-text-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
CompilerWarningEvent,
PipelineEvent,
StatusLineEvent,
TestCaseResultEvent,
TestFailureEvent,
} from '../../types/pipeline-events.ts';
import { createCliProgressReporter } from '../cli-progress-reporter.ts';
Expand All @@ -25,6 +26,7 @@ import {
formatSummaryEvent,
formatNextStepsEvent,
formatTestDiscoveryEvent,
formatTestCaseResults,
} from './event-formatting.ts';

function formatCliTextBlock(text: string): string {
Expand All @@ -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;
Expand Down Expand Up @@ -188,6 +193,13 @@ function createCliTextProcessor(options: CliTextProcessorOptions): PipelineRende
break;
}

case 'test-case-result': {
if (showTestResults) {
collectedTestCaseResults.push(event);
}
break;
}
Comment thread
cursor[bot] marked this conversation as resolved.

case 'summary': {
const diagOpts = { baseDir: diagnosticBaseDir ?? undefined };
const diagnosticSections: string[] = [];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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 {},
Expand Down
22 changes: 22 additions & 0 deletions src/utils/renderers/event-formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
FileRefEvent,
DetailTreeEvent,
SummaryEvent,
TestCaseResultEvent,
TestDiscoveryEvent,
TestFailureEvent,
NextStepsEvent,
Expand Down Expand Up @@ -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<string, string> = {
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');
}
5 changes: 5 additions & 0 deletions src/utils/session-defaults-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const sessionDefaultKeys = [
'useLatestOS',
'arch',
'suppressWarnings',
'showTestResults',
'derivedDataPath',
'preferXcodebuild',
'platform',
Expand All @@ -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.'),
Expand Down
1 change: 1 addition & 0 deletions src/utils/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type SessionDefaults = {
useLatestOS?: boolean;
arch?: 'arm64' | 'x86_64';
suppressWarnings?: boolean;
showTestResults?: boolean;
derivedDataPath?: string;
preferXcodebuild?: boolean;
platform?: string;
Expand Down
Loading
Loading