diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1278934..29d0da2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,9 +4,12 @@
### Fixed
+- Fixed simulator test JSONL accuracy by keeping preflight discovery observational, preserving only user-supplied test selectors, discovering multiline parameterized Swift Testing tests, and parsing destination-suffixed xcodebuild test result lines.
- Removed stale physical-device log session status and shutdown cleanup for deprecated standalone device log capture, and corrected the device build-and-run tool description.
+- Fixed mixed Swift Testing and XCTest summaries so simulator test text output no longer overcounts parameterized Swift Testing results or issue lines.
- Fixed CLI test summaries showing false-positive compiler errors from xcodebuild NSError dump lines, and added compiler-error snapshot coverage for simulator, device, and macOS build-style flows ([#383](https://github.com/getsentry/XcodeBuildMCP/issues/383)).
- Fixed simulator OSLog helper cleanup so server and daemon startup reconcile same-workspace orphaned log streams without stopping helpers owned by live sessions in other workspaces ([#382](https://github.com/getsentry/XcodeBuildMCP/issues/382)).
+- Fixed Weather example test discovery and made CLI test progress visible while tests are running instead of leaving the last build phase displayed.
## [2.5.0-beta.1]
diff --git a/example_projects/Weather/Weather.xcodeproj/xcshareddata/xcschemes/Weather.xcscheme b/example_projects/Weather/Weather.xcodeproj/xcshareddata/xcschemes/Weather.xcscheme
new file mode 100644
index 00000000..55830b38
--- /dev/null
+++ b/example_projects/Weather/Weather.xcodeproj/xcshareddata/xcschemes/Weather.xcscheme
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/utils/__tests__/simulator-test-execution.test.ts b/src/utils/__tests__/simulator-test-execution.test.ts
new file mode 100644
index 00000000..6c39fd15
--- /dev/null
+++ b/src/utils/__tests__/simulator-test-execution.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'vitest';
+import { createSimulatorTwoPhaseExecutionPlan } from '../simulator-test-execution.ts';
+import type { TestPreflightResult } from '../test-preflight.ts';
+
+function createPreflight(): TestPreflightResult {
+ return {
+ scheme: 'CalculatorApp',
+ configuration: 'Debug',
+ projectPath: '/tmp/CalculatorApp.xcodeproj',
+ destinationName: 'iPhone 17 Pro',
+ selectors: { onlyTesting: [], skipTesting: [] },
+ warnings: [],
+ completeness: 'complete',
+ totalTests: 2,
+ targets: [
+ {
+ name: 'CalculatorAppTests',
+ warnings: [],
+ files: [
+ {
+ path: '/tmp/CalculatorAppTests.swift',
+ tests: [
+ {
+ framework: 'xctest',
+ targetName: 'CalculatorAppTests',
+ typeName: 'CalculatorAppTests',
+ methodName: 'testAddition',
+ displayName: 'CalculatorAppTests/CalculatorAppTests/testAddition',
+ line: 10,
+ parameterized: false,
+ },
+ {
+ framework: 'swift-testing',
+ targetName: 'CalculatorAppTests',
+ typeName: 'ExpressionSuite',
+ methodName: 'evaluatesExpression',
+ displayName: 'CalculatorAppTests/ExpressionSuite/evaluatesExpression',
+ line: 20,
+ parameterized: true,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+}
+
+describe('createSimulatorTwoPhaseExecutionPlan', () => {
+ it('keeps preflight discovery observational instead of synthesizing only-testing selectors', () => {
+ const plan = createSimulatorTwoPhaseExecutionPlan({
+ extraArgs: ['-parallel-testing-enabled', 'YES'],
+ preflight: createPreflight(),
+ resultBundlePath: '/tmp/Calculator.xcresult',
+ });
+
+ expect(plan.buildArgs).toEqual(['-parallel-testing-enabled', 'YES']);
+ expect(plan.testArgs).toEqual([
+ '-parallel-testing-enabled',
+ 'YES',
+ '-resultBundlePath',
+ '/tmp/Calculator.xcresult',
+ ]);
+ expect(plan.usesExactSelectors).toBe(false);
+ });
+
+ it('preserves user-supplied selector arguments in both simulator test phases', () => {
+ const plan = createSimulatorTwoPhaseExecutionPlan({
+ extraArgs: [
+ '-only-testing:CalculatorAppTests/CalculatorAppTests/testAddition',
+ '-skip-testing',
+ 'CalculatorAppTests/ExpressionSuite/evaluatesExpression',
+ ],
+ preflight: createPreflight(),
+ });
+
+ expect(plan.buildArgs).toEqual([
+ '-only-testing:CalculatorAppTests/CalculatorAppTests/testAddition',
+ '-skip-testing',
+ 'CalculatorAppTests/ExpressionSuite/evaluatesExpression',
+ ]);
+ expect(plan.testArgs).toEqual(plan.buildArgs);
+ expect(plan.usesExactSelectors).toBe(true);
+ });
+
+ it('keeps resultBundlePath out of build-for-testing args and includes it for test-without-building', () => {
+ const plan = createSimulatorTwoPhaseExecutionPlan({
+ extraArgs: ['-resultBundlePath', '/tmp/UserProvided.xcresult'],
+ });
+
+ expect(plan.buildArgs).toEqual([]);
+ expect(plan.testArgs).toEqual(['-resultBundlePath', '/tmp/UserProvided.xcresult']);
+ });
+});
diff --git a/src/utils/__tests__/swift-test-discovery.test.ts b/src/utils/__tests__/swift-test-discovery.test.ts
new file mode 100644
index 00000000..580eefa5
--- /dev/null
+++ b/src/utils/__tests__/swift-test-discovery.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts';
+import { discoverSwiftTestsInFiles } from '../swift-test-discovery.ts';
+
+describe('discoverSwiftTestsInFiles', () => {
+ it('discovers Swift Testing functions with multiline parameterized Test attributes', async () => {
+ const filePath = '/tmp/CalculatorServiceTests.swift';
+ const fileSystemExecutor = createMockFileSystemExecutor({
+ readFile: async () => `
+import Testing
+
+struct CalculatorServiceTests {
+ @Test(
+ "evaluates decimal operations",
+ arguments: [
+ ("1 + 1", "2"),
+ ("4 / 2", "2"),
+ ]
+ )
+ func evaluatesDecimalOperations(expression: String, expected: String) async throws {}
+
+ @Test(arguments: ["+", "-", "×"])
+ func evaluatesOperators(symbol: String) async throws {}
+}
+`,
+ });
+
+ const files = await discoverSwiftTestsInFiles(
+ 'CalculatorAppFeatureTests',
+ [filePath],
+ fileSystemExecutor,
+ );
+
+ expect(files).toHaveLength(1);
+ expect(files[0].tests).toMatchObject([
+ {
+ framework: 'swift-testing',
+ targetName: 'CalculatorAppFeatureTests',
+ typeName: 'CalculatorServiceTests',
+ methodName: 'evaluatesDecimalOperations',
+ displayName: 'CalculatorAppFeatureTests/CalculatorServiceTests/evaluatesDecimalOperations',
+ parameterized: true,
+ },
+ {
+ framework: 'swift-testing',
+ targetName: 'CalculatorAppFeatureTests',
+ typeName: 'CalculatorServiceTests',
+ methodName: 'evaluatesOperators',
+ displayName: 'CalculatorAppFeatureTests/CalculatorServiceTests/evaluatesOperators',
+ parameterized: true,
+ },
+ ]);
+ });
+});
diff --git a/src/utils/__tests__/swift-testing-line-parsers.test.ts b/src/utils/__tests__/swift-testing-line-parsers.test.ts
index 4b49258c..c810cba1 100644
--- a/src/utils/__tests__/swift-testing-line-parsers.test.ts
+++ b/src/utils/__tests__/swift-testing-line-parsers.test.ts
@@ -4,7 +4,6 @@ import {
parseSwiftTestingIssueLine,
parseSwiftTestingRunSummary,
parseSwiftTestingContinuationLine,
- parseXcodebuildSwiftTestingLine,
} from '../swift-testing-line-parsers.ts';
describe('Swift Testing line parsers', () => {
@@ -259,42 +258,4 @@ describe('Swift Testing line parsers', () => {
expect(parseSwiftTestingContinuationLine('regular line')).toBeNull();
});
});
-
- describe('parseXcodebuildSwiftTestingLine', () => {
- it('should parse a passed test case', () => {
- const result = parseXcodebuildSwiftTestingLine(
- "Test case 'MCPTestTests/appNameIsCorrect()' passed on 'My Mac - MCPTest (78757)' (0.000 seconds)",
- );
- expect(result).toEqual({
- status: 'passed',
- rawName: 'MCPTestTests/appNameIsCorrect()',
- suiteName: 'MCPTestTests',
- testName: 'appNameIsCorrect()',
- durationText: '0.000s',
- });
- });
-
- it('should parse a failed test case', () => {
- const result = parseXcodebuildSwiftTestingLine(
- "Test case 'MCPTestTests/deliberateFailure()' failed on 'My Mac - MCPTest (78757)' (0.000 seconds)",
- );
- expect(result).toEqual({
- status: 'failed',
- rawName: 'MCPTestTests/deliberateFailure()',
- suiteName: 'MCPTestTests',
- testName: 'deliberateFailure()',
- durationText: '0.000s',
- });
- });
-
- it('should return null for XCTest format lines', () => {
- expect(
- parseXcodebuildSwiftTestingLine("Test Case '-[Suite test]' passed (0.001 seconds)."),
- ).toBeNull();
- });
-
- it('should return null for non-matching lines', () => {
- expect(parseXcodebuildSwiftTestingLine('random text')).toBeNull();
- });
- });
});
diff --git a/src/utils/__tests__/test-common.test.ts b/src/utils/__tests__/test-common.test.ts
index e994d1f5..9006bca1 100644
--- a/src/utils/__tests__/test-common.test.ts
+++ b/src/utils/__tests__/test-common.test.ts
@@ -1,5 +1,58 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { resolveTestProgressEnabled } from '../test-common.ts';
+import type { ChildProcess } from 'node:child_process';
+import { createTestExecutor, resolveTestProgressEnabled } from '../test-common.ts';
+import type { CommandExecutor, CommandResponse } from '../command.ts';
+import { DefaultStreamingExecutionContext } from '../execution/index.ts';
+import type { AnyFragment } from '../../types/domain-fragments.ts';
+import type { TestPreflightResult } from '../test-preflight.ts';
+import { XcodePlatform } from '../xcode.ts';
+
+function createSuccessfulCommandResponse(): CommandResponse {
+ return {
+ success: true,
+ output: '',
+ process: { pid: 12345 } as ChildProcess,
+ exitCode: 0,
+ };
+}
+
+function createPreflight(): TestPreflightResult {
+ return {
+ scheme: 'Weather',
+ configuration: 'Debug',
+ projectPath: 'Weather.xcodeproj',
+ destinationName: 'iPhone 17 Pro',
+ selectors: {
+ onlyTesting: [],
+ skipTesting: [],
+ },
+ targets: [
+ {
+ name: 'WeatherTests',
+ files: [
+ {
+ path: 'WeatherTests/WeatherTests.swift',
+ tests: [
+ {
+ framework: 'swift-testing',
+ targetName: 'WeatherTests',
+ typeName: 'WeatherTests',
+ methodName: 'emptySearchReturnsNoResults',
+ displayName: 'WeatherTests/WeatherTests/emptySearchReturnsNoResults',
+ line: 12,
+ parameterized: false,
+ },
+ ],
+ },
+ ],
+ warnings: [],
+ },
+ ],
+ warnings: [],
+ totalTests: 1,
+ completeness: 'complete',
+ };
+}
describe('resolveTestProgressEnabled', () => {
const originalRuntime = process.env.XCODEBUILDMCP_RUNTIME;
@@ -39,3 +92,60 @@ describe('resolveTestProgressEnabled', () => {
expect(resolveTestProgressEnabled(false)).toBe(false);
});
});
+
+describe('createTestExecutor', () => {
+ it('emits RUN_TESTS before test-without-building starts in two-phase simulator execution', async () => {
+ const emitted: AnyFragment[] = [];
+ const actions: string[] = [];
+ const executor: CommandExecutor = async (command, _logPrefix, _useShell, opts) => {
+ const action = command.at(-1);
+ if (action) {
+ actions.push(action);
+ }
+
+ if (action === 'build-for-testing') {
+ opts?.onStdout?.('Ld /tmp/Weather.build/Weather normal arm64\n');
+ }
+
+ return createSuccessfulCommandResponse();
+ };
+
+ const executeTest = createTestExecutor(executor, {
+ preflight: createPreflight(),
+ toolName: 'test_sim',
+ target: 'simulator',
+ request: {
+ scheme: 'Weather',
+ projectPath: 'Weather.xcodeproj',
+ configuration: 'Debug',
+ platform: XcodePlatform.iOSSimulator,
+ },
+ });
+
+ await executeTest(
+ {
+ projectPath: 'Weather.xcodeproj',
+ scheme: 'Weather',
+ configuration: 'Debug',
+ simulatorId: 'A2C64636-37E9-4B68-B872-E7F0A82A5670',
+ platform: XcodePlatform.iOSSimulator,
+ },
+ new DefaultStreamingExecutionContext({
+ onFragment: (fragment) => emitted.push(fragment),
+ }),
+ );
+
+ expect(actions).toEqual(['build-for-testing', 'test-without-building']);
+
+ const stageEvents = emitted.filter((event) => event.fragment === 'build-stage');
+ expect(stageEvents.map((event) => event.stage)).toEqual(['LINKING', 'RUN_TESTS']);
+
+ const runTestsIndex = emitted.findIndex(
+ (event) => event.fragment === 'build-stage' && event.stage === 'RUN_TESTS',
+ );
+ const finalSummaryIndex = emitted.findIndex((event) => event.fragment === 'build-summary');
+
+ expect(runTestsIndex).toBeGreaterThan(-1);
+ expect(finalSummaryIndex).toBeGreaterThan(runTestsIndex);
+ });
+});
diff --git a/src/utils/__tests__/xcodebuild-event-parser.test.ts b/src/utils/__tests__/xcodebuild-event-parser.test.ts
index a49ed332..f9cfa5e4 100644
--- a/src/utils/__tests__/xcodebuild-event-parser.test.ts
+++ b/src/utils/__tests__/xcodebuild-event-parser.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { createXcodebuildEventParser } from '../xcodebuild-event-parser.ts';
+import { createXcodebuildRunState } from '../xcodebuild-run-state.ts';
import type { DomainFragment } from '../../types/domain-fragments.ts';
function collectEvents(
@@ -24,6 +25,43 @@ function collectEvents(
return events;
}
+function collectRunStateEvents(
+ lines: { source: 'stdout' | 'stderr'; text: string }[],
+): DomainFragment[] {
+ const events: DomainFragment[] = [];
+ const runState = createXcodebuildRunState({
+ operation: 'TEST',
+ onEvent: (event) => events.push(event),
+ });
+ const parser = createXcodebuildEventParser({
+ operation: 'TEST',
+ onEvent: (event) => {
+ switch (event.fragment) {
+ case 'build-stage':
+ case 'compiler-diagnostic':
+ case 'test-discovery':
+ case 'test-failure':
+ case 'test-progress':
+ case 'test-case-result':
+ runState.push(event);
+ break;
+ }
+ },
+ });
+
+ for (const { source, text } of lines) {
+ if (source === 'stdout') {
+ parser.onStdout(text);
+ } else {
+ parser.onStderr(text);
+ }
+ }
+
+ parser.flush();
+ runState.finalize(false);
+ return events;
+}
+
describe('xcodebuild-event-parser', () => {
it('emits status events for package resolution', () => {
const events = collectEvents('TEST', [{ source: 'stdout', text: 'Resolve Package Graph\n' }]);
@@ -75,6 +113,31 @@ describe('xcodebuild-event-parser', () => {
});
});
+ it('emits running-tests status for test suite and case start lines', () => {
+ const events = collectEvents('TEST', [
+ {
+ source: 'stdout',
+ text: "Test suite 'WeatherUITests' started on 'Clone 1 of iPhone 17 Pro - WeatherUITests-Runner (12147)'\n",
+ },
+ {
+ source: 'stdout',
+ text: "Test case 'WeatherTests/emptySearchReturnsNoResults()' started on 'Clone 2 of iPhone 17 Pro - Weather (12472)'\n",
+ },
+ {
+ source: 'stdout',
+ text: '◇ Test "Calculator initializes with correct default values" started.\n',
+ },
+ ]);
+
+ const stages = events.filter((event) => event.fragment === 'build-stage');
+ expect(stages).toHaveLength(3);
+ expect(stages).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ fragment: 'build-stage', stage: 'RUN_TESTS' }),
+ ]),
+ );
+ });
+
it('emits test-progress events with cumulative counts', () => {
const events = collectEvents('TEST', [
{ source: 'stdout', text: "Test Case '-[Suite testA]' passed (0.001 seconds)\n" },
@@ -115,6 +178,50 @@ describe('xcodebuild-event-parser', () => {
});
});
+ it('parses modern xcodebuild Test Case lines with destinations', () => {
+ const events = collectEvents('TEST', [
+ {
+ source: 'stdout',
+ text: "Test Case '-[WeatherTests testForecast]' passed on 'iPhone 16 Pro' (0.010 seconds)\n",
+ },
+ {
+ source: 'stdout',
+ text: "Test case 'WeatherUITests/testSearch()' failed on 'Clone 1 of iPhone 16 Pro' (0.250 seconds)\n",
+ },
+ {
+ source: 'stdout',
+ text: "Test case 'WeatherUITests/testOfflineMode()' skipped on 'Clone 2 of iPhone 16 Pro' (0.001 seconds)\n",
+ },
+ ]);
+
+ const cases = events.filter((e) => e.fragment === 'test-case-result');
+ expect(cases).toHaveLength(3);
+ expect(cases[0]).toMatchObject({
+ suite: 'WeatherTests',
+ test: 'testForecast',
+ status: 'passed',
+ durationMs: 10,
+ });
+ expect(cases[1]).toMatchObject({
+ suite: 'WeatherUITests',
+ test: 'testSearch()',
+ status: 'failed',
+ durationMs: 250,
+ });
+ expect(cases[2]).toMatchObject({
+ suite: 'WeatherUITests',
+ test: 'testOfflineMode()',
+ status: 'skipped',
+ durationMs: 1,
+ });
+
+ const progressEvents = events.filter((e) => e.fragment === 'test-progress');
+ expect(progressEvents).toHaveLength(3);
+ expect(progressEvents[0]).toMatchObject({ completed: 1, failed: 0, skipped: 0 });
+ expect(progressEvents[1]).toMatchObject({ completed: 2, failed: 1, skipped: 0 });
+ expect(progressEvents[2]).toMatchObject({ completed: 3, failed: 1, skipped: 1 });
+ });
+
it('emits test-case-result events for Swift Testing passed/failed lines', () => {
const events = collectEvents('TEST', [
{ source: 'stdout', text: '✔ Test "passingTest()" passed after 0.005 seconds.\n' },
@@ -398,6 +505,205 @@ describe('xcodebuild-event-parser', () => {
});
});
+ it('uses Swift Testing and XCTest summaries once for mixed Calculator test output', () => {
+ const xctestPassedLines = Array.from({ length: 21 }, (_, index) => ({
+ source: 'stdout' as const,
+ text: `Test Case '-[CalculatorAppTests.CalculatorAppTests testPassing${index + 1}]' passed (0.001 seconds).\n`,
+ }));
+ const events = collectRunStateEvents([
+ {
+ source: 'stdout',
+ text: '✔ Test "Adding single digit numbers" passed after 0.016 seconds.\n',
+ },
+ {
+ source: 'stdout',
+ text: '\u200B✔ Test "Adding decimal numbers" passed after 0.012 seconds.\n',
+ },
+ {
+ source: 'stdout',
+ text: '✔ Test "Addition operation" with 4 test cases passed after 0.005 seconds.\n',
+ },
+ {
+ source: 'stdout',
+ text: '✘ Test "This test should fail to verify error reporting" recorded an issue at CalculatorServiceTests.swift:37:9: Expectation failed: (calculator.display → "0") == "999"\n',
+ },
+ {
+ source: 'stdout',
+ text: '✘ Test "This test should fail to verify error reporting" failed after 0.029 seconds with 1 issue.\n',
+ },
+ {
+ source: 'stdout',
+ text: '✘ Test run with 34 tests in 9 suites failed after 0.047 seconds with 1 issue.\n',
+ },
+ ...xctestPassedLines,
+ {
+ source: 'stderr',
+ text: '/Volumes/Developer/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52: error: -[CalculatorAppTests.CalculatorAppTests testCalculatorServiceFailure] : XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999\n',
+ },
+ {
+ source: 'stdout',
+ text: "Test Case '-[CalculatorAppTests.CalculatorAppTests testCalculatorServiceFailure]' failed (0.004 seconds).\n",
+ },
+ {
+ source: 'stderr',
+ text: '/Volumes/Developer/XcodeBuildMCP/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286: error: -[CalculatorAppTests.IntentionalFailureTests test] : XCTAssertTrue failed - This test should fail to verify error reporting\n',
+ },
+ {
+ source: 'stdout',
+ text: "Test Case '-[CalculatorAppTests.IntentionalFailureTests test]' failed (0.003 seconds).\n",
+ },
+ {
+ source: 'stdout',
+ text: '\t Executed 23 tests, with 2 failures (0 unexpected) in 0.654 (0.665) seconds\n',
+ },
+ ]);
+
+ const summary = events.filter((event) => event.fragment === 'build-summary').at(-1);
+ expect(summary).toMatchObject({
+ fragment: 'build-summary',
+ operation: 'TEST',
+ totalTests: 57,
+ passedTests: 54,
+ failedTests: 3,
+ skippedTests: 0,
+ });
+ });
+
+ it('reconciles separate Swift Testing run summaries independently', () => {
+ const events = collectRunStateEvents([
+ {
+ source: 'stdout',
+ text: '✔ Test "First target test" passed after 0.001 seconds.\n',
+ },
+ {
+ source: 'stdout',
+ text: '✔ Test run with 1 test in 1 suite passed after 0.001 seconds.\n',
+ },
+ {
+ source: 'stdout',
+ text: '✔ Test "Second target parameterized test" with 4 test cases passed after 0.002 seconds.\n',
+ },
+ {
+ source: 'stdout',
+ text: '✔ Test run with 1 test in 1 suite passed after 0.002 seconds.\n',
+ },
+ ]);
+
+ const summary = events.filter((event) => event.fragment === 'build-summary').at(-1);
+ expect(summary).toMatchObject({
+ fragment: 'build-summary',
+ operation: 'TEST',
+ totalTests: 2,
+ passedTests: 2,
+ failedTests: 0,
+ skippedTests: 0,
+ });
+ });
+
+ it('keeps Swift Testing summary progress monotonic when per-test lines exceed the summary', () => {
+ const events = collectEvents('TEST', [
+ {
+ source: 'stdout',
+ text: '✔ Test "First observed case" passed after 0.001 seconds.\n',
+ },
+ {
+ source: 'stdout',
+ text: '✔ Test "Second observed case" passed after 0.001 seconds.\n',
+ },
+ {
+ source: 'stdout',
+ text: '✔ Test run with 1 test in 1 suite passed after 0.001 seconds.\n',
+ },
+ ]);
+
+ const progress = events.filter((event) => event.fragment === 'test-progress');
+ expect(progress).toEqual([
+ expect.objectContaining({ completed: 1, failed: 0, skipped: 0 }),
+ expect.objectContaining({ completed: 2, failed: 0, skipped: 0 }),
+ expect.objectContaining({ completed: 2, failed: 0, skipped: 0 }),
+ ]);
+ });
+
+ it('keeps XCTest-style test case lines independent from Swift Testing summaries', () => {
+ const events = collectEvents('TEST', [
+ {
+ source: 'stdout',
+ text: "Test case 'WeatherUITests.testSearch()' passed on 'Clone 1' (0.001 seconds)\n",
+ },
+ {
+ source: 'stdout',
+ text: '✔ Test run with 1 test in 1 suite passed after 0.001 seconds.\n',
+ },
+ ]);
+
+ const progress = events.filter((event) => event.fragment === 'test-progress');
+ expect(progress).toEqual([
+ expect.objectContaining({ completed: 1, failed: 0, skipped: 0 }),
+ expect.objectContaining({ completed: 2, failed: 0, skipped: 0 }),
+ ]);
+ });
+
+ it('does not double-count xcodebuild-formatted Swift Testing lines before a summary', () => {
+ const events = collectEvents('TEST', [
+ {
+ source: 'stdout',
+ text: "Test case 'WeatherTests/emptySearchReturnsNoResults()' passed on 'Clone 1' (0.001 seconds)\n",
+ },
+ {
+ source: 'stdout',
+ text: '✔ Test run with 1 test in 1 suite passed after 0.001 seconds.\n',
+ },
+ ]);
+
+ const progress = events.filter((event) => event.fragment === 'test-progress');
+ expect(progress).toEqual([
+ expect.objectContaining({ completed: 1, failed: 0, skipped: 0 }),
+ expect.objectContaining({ completed: 1, failed: 0, skipped: 0 }),
+ ]);
+ });
+
+ it('counts additional failures reported only by a Swift Testing summary', () => {
+ const events = collectEvents('TEST', [
+ {
+ source: 'stdout',
+ text: '✘ Test "Individually reported failure" failed after 0.001 seconds with 1 issue.\n',
+ },
+ {
+ source: 'stdout',
+ text: '✘ Test run with 2 tests in 1 suite failed after 0.001 seconds with 2 issues.\n',
+ },
+ ]);
+
+ const progress = events.filter((event) => event.fragment === 'test-progress');
+ expect(progress).toEqual([
+ expect.objectContaining({ completed: 1, failed: 1, skipped: 0 }),
+ expect.objectContaining({ completed: 2, failed: 2, skipped: 0 }),
+ ]);
+ });
+
+ it('keeps parameterized Swift Testing result counts aligned with the run summary', () => {
+ const events = collectRunStateEvents([
+ {
+ source: 'stdout',
+ text: '✔ Test "Parameterized test" with 4 test cases passed after 0.001 seconds.\n',
+ },
+ {
+ source: 'stdout',
+ text: '✔ Test run with 1 test in 1 suite passed after 0.001 seconds.\n',
+ },
+ ]);
+
+ const summary = events.filter((event) => event.fragment === 'build-summary').at(-1);
+ expect(summary).toMatchObject({
+ fragment: 'build-summary',
+ operation: 'TEST',
+ totalTests: 1,
+ passedTests: 1,
+ failedTests: 0,
+ skippedTests: 0,
+ });
+ });
+
it('processes full test lifecycle', () => {
const events = collectEvents('TEST', [
{ source: 'stdout', text: 'Resolve Package Graph\n' },
@@ -420,7 +726,7 @@ describe('xcodebuild-event-parser', () => {
expect(fragments).toContain('test-failure');
});
- it('increments counts by caseCount for parameterized Swift Testing results', () => {
+ it('counts parameterized Swift Testing result lines once for progress', () => {
const events = collectEvents('TEST', [
{
source: 'stdout',
@@ -431,7 +737,7 @@ describe('xcodebuild-event-parser', () => {
const progress = events.filter((e) => e.fragment === 'test-progress');
expect(progress).toHaveLength(1);
if (progress[0].fragment === 'test-progress') {
- expect(progress[0].completed).toBe(3);
+ expect(progress[0].completed).toBe(1);
}
});
diff --git a/src/utils/renderers/__tests__/cli-text-renderer.test.ts b/src/utils/renderers/__tests__/cli-text-renderer.test.ts
index d112d7ea..2a645b0b 100644
--- a/src/utils/renderers/__tests__/cli-text-renderer.test.ts
+++ b/src/utils/renderers/__tests__/cli-text-renderer.test.ts
@@ -145,6 +145,66 @@ describe('cli-text-renderer', () => {
expect(output).toContain('\u{2705} Resolving app path\n');
});
+ it('replaces interactive build-stage updates with test progress updates', () => {
+ const renderer = createCliTextRenderer({ interactive: true });
+
+ renderer.onFragment({
+ kind: 'test-result',
+ fragment: 'build-stage',
+ operation: 'TEST',
+ stage: 'LINKING',
+ message: 'Linking',
+ });
+
+ renderer.onFragment({
+ kind: 'test-result',
+ fragment: 'test-progress',
+ operation: 'TEST',
+ completed: 4,
+ failed: 0,
+ skipped: 0,
+ });
+
+ expect(reporter.update).toHaveBeenCalledWith('Linking...');
+ expect(reporter.update).toHaveBeenCalledWith(
+ 'Running tests (4 completed, 0 failures, 0 skipped)',
+ );
+ });
+
+ it('renders non-interactive test progress durably and deduplicates repeated counts', () => {
+ const output = renderCliTextTranscript({
+ items: [
+ {
+ kind: 'test-result',
+ fragment: 'test-progress',
+ operation: 'TEST',
+ completed: 1,
+ failed: 0,
+ skipped: 0,
+ },
+ {
+ kind: 'test-result',
+ fragment: 'test-progress',
+ operation: 'TEST',
+ completed: 1,
+ failed: 0,
+ skipped: 0,
+ },
+ {
+ kind: 'test-result',
+ fragment: 'test-progress',
+ operation: 'TEST',
+ completed: 2,
+ failed: 0,
+ skipped: 0,
+ },
+ ],
+ });
+
+ expect(output.match(/Running tests \(1 completed, 0 failures, 0 skipped\)/g)).toHaveLength(1);
+ expect(output).toContain('Running tests (2 completed, 0 failures, 0 skipped)');
+ });
+
it('renders grouped sad-path diagnostics before the failed summary', () => {
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const renderer = createCliTextRenderer({ interactive: false });
diff --git a/src/utils/renderers/cli-text-renderer.ts b/src/utils/renderers/cli-text-renderer.ts
index 897df0cc..9cbc0969 100644
--- a/src/utils/renderers/cli-text-renderer.ts
+++ b/src/utils/renderers/cli-text-renderer.ts
@@ -39,6 +39,7 @@ import {
formatNextStepsEvent,
formatTestCaseResults,
formatTestDiscoveryEvent,
+ formatTestProgressEvent,
} from './event-formatting.ts';
import {
createXcodebuildEventParser,
@@ -112,6 +113,7 @@ function createCliTextProcessor(options: CliTextProcessorOptions): TranscriptRen
let nextSteps: readonly NextStep[] = [];
let nextStepsRuntime: 'cli' | 'daemon' | 'mcp' | undefined;
let sawProgressNextSteps = false;
+ let lastRenderedTestProgressKey: string | null = null;
function writeDurable(text: string): void {
sink.clearTransient();
@@ -261,10 +263,16 @@ function createCliTextProcessor(options: CliTextProcessorOptions): TranscriptRen
}
case 'test-progress': {
+ const renderedProgress = formatTestProgressEvent(item);
+ const progressKey = `${item.completed}:${item.failed}:${item.skipped}`;
+ pendingTransientRuntimeLine = null;
if (interactive) {
- const failWord = item.failed === 1 ? 'failure' : 'failures';
- pendingTransientRuntimeLine = null;
- sink.updateTransient(`Running tests (${item.completed}, ${item.failed} ${failWord})`);
+ sink.updateTransient(renderedProgress);
+ } else if (progressKey !== lastRenderedTestProgressKey) {
+ writeDurable(renderedProgress);
+ lastRenderedTestProgressKey = progressKey;
+ lastVisibleEventType = 'test-progress';
+ lastStatusLineLevel = null;
}
break;
}
@@ -442,6 +450,7 @@ function createCliTextProcessor(options: CliTextProcessorOptions): TranscriptRen
parserStates.clear();
sawProgressNextSteps = false;
collectedTestCaseResults.length = 0;
+ lastRenderedTestProgressKey = null;
},
};
}
diff --git a/src/utils/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts
index 76b55622..3c4e3b48 100644
--- a/src/utils/renderers/event-formatting.ts
+++ b/src/utils/renderers/event-formatting.ts
@@ -15,6 +15,7 @@ import type {
TestCaseResultRenderItem,
TestDiscoveryRenderItem,
TestFailureRenderItem,
+ TestProgressRenderItem,
} from '../../rendering/render-items.ts';
import type {
DetailTreeTextBlock,
@@ -547,6 +548,11 @@ export function formatTestDiscoveryEvent(event: TestDiscoveryRenderItem): string
return lines.join('\n');
}
+export function formatTestProgressEvent(event: TestProgressRenderItem): string {
+ const failWord = event.failed === 1 ? 'failure' : 'failures';
+ return `Running tests (${event.completed} completed, ${event.failed} ${failWord}, ${event.skipped} skipped)`;
+}
+
export function formatNextStepsEvent(event: NextStepsTextBlock, runtime: 'cli' | 'mcp'): string {
return renderNextStepsSection(event.steps, runtime);
}
diff --git a/src/utils/simulator-test-execution.ts b/src/utils/simulator-test-execution.ts
index 5d2b08b1..c23e1bf3 100644
--- a/src/utils/simulator-test-execution.ts
+++ b/src/utils/simulator-test-execution.ts
@@ -1,4 +1,4 @@
-import { collectResolvedTestSelectors, type TestPreflightResult } from './test-preflight.ts';
+import type { TestPreflightResult } from './test-preflight.ts';
function parseTestSelectorArgs(extraArgs: string[] | undefined): {
remainingArgs: string[];
@@ -55,11 +55,8 @@ export function createSimulatorTwoPhaseExecutionPlan(params: {
usesExactSelectors: boolean;
} {
const parsedArgs = parseTestSelectorArgs(params.extraArgs);
- const resolvedSelectors = params.preflight ? collectResolvedTestSelectors(params.preflight) : [];
- const exactSelectorArgs = resolvedSelectors.flatMap((selector) => [`-only-testing:${selector}`]);
- const usesExactSelectors = exactSelectorArgs.length > 0;
-
- const selectedTestArgs = usesExactSelectors ? exactSelectorArgs : parsedArgs.selectorArgs;
+ const selectedTestArgs = parsedArgs.selectorArgs;
+ const usesExactSelectors = selectedTestArgs.length > 0;
const resultBundlePath = params.resultBundlePath ?? parsedArgs.resultBundlePath;
return {
diff --git a/src/utils/swift-test-discovery.ts b/src/utils/swift-test-discovery.ts
index 6830795a..8394e59d 100644
--- a/src/utils/swift-test-discovery.ts
+++ b/src/utils/swift-test-discovery.ts
@@ -94,6 +94,18 @@ function countBraces(line: string): number {
return delta;
}
+function countParentheses(line: string): number {
+ let delta = 0;
+ for (const character of line) {
+ if (character === '(') {
+ delta += 1;
+ } else if (character === ')') {
+ delta -= 1;
+ }
+ }
+ return delta;
+}
+
function collectXCTestTypes(lines: string[]): Set {
const xctestTypes = new Set();
@@ -138,6 +150,8 @@ function discoverTestsInFileContent(
const scopeStack: Array<{ typeName?: string; xctestContext: boolean; depth: number }> = [];
let braceDepth = 0;
let pendingAttributes: string[] = [];
+ let pendingMultilineAttributeIndex: number | null = null;
+ let pendingAttributeParenthesisDepth = 0;
sanitizedLines.forEach((sanitizedLine, index) => {
const lineNumber = index + 1;
@@ -147,8 +161,27 @@ function discoverTestsInFileContent(
scopeStack.pop();
}
+ let consumedAttributeContinuation = false;
+
if (line.startsWith('@')) {
pendingAttributes.push(line);
+ const parenthesisDepth = countParentheses(line);
+ if (parenthesisDepth > 0) {
+ pendingMultilineAttributeIndex = pendingAttributes.length - 1;
+ pendingAttributeParenthesisDepth = parenthesisDepth;
+ } else {
+ pendingMultilineAttributeIndex = null;
+ pendingAttributeParenthesisDepth = 0;
+ }
+ } else if (pendingMultilineAttributeIndex !== null) {
+ pendingAttributes[pendingMultilineAttributeIndex] =
+ `${pendingAttributes[pendingMultilineAttributeIndex]} ${line}`;
+ pendingAttributeParenthesisDepth += countParentheses(line);
+ consumedAttributeContinuation = true;
+ if (pendingAttributeParenthesisDepth <= 0) {
+ pendingMultilineAttributeIndex = null;
+ pendingAttributeParenthesisDepth = 0;
+ }
}
const typeMatch = line.match(
@@ -203,7 +236,14 @@ function discoverTestsInFileContent(
}
pendingAttributes = [];
- } else if (line.length > 0 && !line.startsWith('@')) {
+ pendingMultilineAttributeIndex = null;
+ pendingAttributeParenthesisDepth = 0;
+ } else if (
+ line.length > 0 &&
+ !line.startsWith('@') &&
+ !consumedAttributeContinuation &&
+ pendingMultilineAttributeIndex === null
+ ) {
pendingAttributes = [];
}
diff --git a/src/utils/swift-testing-line-parsers.ts b/src/utils/swift-testing-line-parsers.ts
index 1ee463e2..dddf20ef 100644
--- a/src/utils/swift-testing-line-parsers.ts
+++ b/src/utils/swift-testing-line-parsers.ts
@@ -176,29 +176,3 @@ export function parseSwiftTestingContinuationLine(line: string): string | null {
const match = line.match(/^↳ (.+)$/u);
return match ? match[1] : null;
}
-
-/**
- * Parse xcodebuild's Swift Testing format.
- *
- * Matches:
- * Test case 'Suite/testName()' passed on 'My Mac - App (12345)' (0.001 seconds)
- * Test case 'Suite/testName()' failed on 'My Mac - App (12345)' (0.001 seconds)
- */
-export function parseXcodebuildSwiftTestingLine(line: string): ParsedTestCase | null {
- const match = line.match(
- /^Test case '(.+)' (passed|failed|skipped) on '.+' \(([^)]+) seconds?\)$/u,
- );
- if (!match) {
- return null;
- }
- const [, rawName, status, duration] = match;
- const { suiteName, testName } = parseRawTestName(rawName);
-
- return {
- status: status as 'passed' | 'failed' | 'skipped',
- rawName,
- suiteName,
- testName,
- durationText: `${duration}s`,
- };
-}
diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts
index 759ce669..e22fe6b7 100644
--- a/src/utils/test-common.ts
+++ b/src/utils/test-common.ts
@@ -153,6 +153,14 @@ export function createTestExecutor(
});
}
+ started.pipeline.emitFragment({
+ kind: 'test-result',
+ fragment: 'build-stage',
+ operation: 'TEST',
+ stage: 'RUN_TESTS',
+ message: 'Running tests',
+ });
+
const testWithoutBuildingResult = await executeXcodeBuildCommand(
{ ...params, extraArgs: executionPlan.testArgs },
platformOptions,
diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts
index e32f5f39..2698be05 100644
--- a/src/utils/xcodebuild-event-parser.ts
+++ b/src/utils/xcodebuild-event-parser.ts
@@ -13,7 +13,6 @@ import {
type ParsedTestCase,
} from './xcodebuild-line-parsers.ts';
import {
- parseXcodebuildSwiftTestingLine,
parseSwiftTestingIssueLine,
parseSwiftTestingResultLine,
parseSwiftTestingRunSummary,
@@ -33,7 +32,10 @@ function resolveStageFromLine(line: string): XcodebuildStage | null {
if (
/^Testing started$/u.test(line) ||
/^Test [Ss]uite .+ started/u.test(line) ||
- /^[◇] Test run started/u.test(line)
+ /^Test [Cc]ase .+ started/u.test(line) ||
+ /^[◇] Test run started/u.test(line) ||
+ /^[◇] Test .+ started/u.test(line) ||
+ /^[◇] Test case .+ started/u.test(line)
) {
return 'RUN_TESTS';
}
@@ -88,6 +90,10 @@ function isIgnoredNoiseLine(line: string): boolean {
return IGNORED_NOISE_PATTERNS.some((pattern) => pattern.test(line));
}
+function normalizeEventLine(rawLine: string): string {
+ return rawLine.trim().replace(/^(?:\u200B|\u200C|\u200D|\uFEFF)+/u, '');
+}
+
export interface EventParserOptions {
operation: XcodebuildOperation;
kind?: BuildLikeKind;
@@ -112,6 +118,8 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb
let completedCount = 0;
let failedCount = 0;
let skippedCount = 0;
+ let testCasesCompletedSinceSwiftTestingSummary = 0;
+ let testCasesFailedSinceSwiftTestingSummary = 0;
let detectedXcresultPath: string | null = null;
let pendingError: {
@@ -224,8 +232,11 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb
});
}
- function recordTestCaseResult(testCase: ParsedTestCase): void {
- const increment = testCase.caseCount ?? 1;
+ function recordTestCaseResult(
+ testCase: ParsedTestCase,
+ source: 'xcodebuild' | 'swift-testing' = 'xcodebuild',
+ ): void {
+ const increment = 1;
completedCount += increment;
const durationMs = parseDurationMs(testCase.durationText);
@@ -236,6 +247,13 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb
skippedCount += increment;
}
+ if (source === 'swift-testing') {
+ testCasesCompletedSinceSwiftTestingSummary += increment;
+ if (testCase.status === 'failed') {
+ testCasesFailedSinceSwiftTestingSummary += increment;
+ }
+ }
+
if (operation === 'TEST') {
onEvent({
kind: 'test-result',
@@ -267,7 +285,7 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb
}
function processLine(rawLine: string): void {
- const line = rawLine.trim();
+ const line = normalizeEventLine(rawLine);
if (!line) {
flushPendingError();
return;
@@ -292,7 +310,11 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb
const testCase = parseTestCaseLine(line);
if (testCase) {
- recordTestCaseResult(testCase);
+ const source =
+ /^Test case /u.test(line) && /\/.+\(\)$/u.test(testCase.rawName)
+ ? 'swift-testing'
+ : 'xcodebuild';
+ recordTestCaseResult(testCase, source);
return;
}
@@ -310,12 +332,6 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb
return;
}
- const xcodebuildST = parseXcodebuildSwiftTestingLine(line);
- if (xcodebuildST) {
- recordTestCaseResult(xcodebuildST);
- return;
- }
-
const stIssue = parseSwiftTestingIssueLine(line);
if (stIssue) {
queueFailureDiagnostic(stIssue);
@@ -324,14 +340,19 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb
const stResult = parseSwiftTestingResultLine(line);
if (stResult) {
- recordTestCaseResult(stResult);
+ recordTestCaseResult(stResult, 'swift-testing');
return;
}
const stSummary = parseSwiftTestingRunSummary(line);
if (stSummary) {
- completedCount = Math.max(completedCount, stSummary.executed);
- failedCount = Math.max(failedCount, stSummary.failed);
+ completedCount += Math.max(
+ 0,
+ stSummary.executed - testCasesCompletedSinceSwiftTestingSummary,
+ );
+ failedCount += Math.max(0, stSummary.failed - testCasesFailedSinceSwiftTestingSummary);
+ testCasesCompletedSinceSwiftTestingSummary = 0;
+ testCasesFailedSinceSwiftTestingSummary = 0;
emitTestProgress();
return;
}
diff --git a/src/utils/xcodebuild-line-parsers.ts b/src/utils/xcodebuild-line-parsers.ts
index 3e81fb40..99072d6c 100644
--- a/src/utils/xcodebuild-line-parsers.ts
+++ b/src/utils/xcodebuild-line-parsers.ts
@@ -88,7 +88,9 @@ export function parseRawTestName(rawName: string): { suiteName?: string; testNam
}
export function parseTestCaseLine(line: string): ParsedTestCase | null {
- const match = line.match(/^Test Case '(.+)' (passed|failed|skipped) \(([^)]+)\)/u);
+ const match = line.match(
+ /^Test [Cc]ase '(.+)' (passed|failed|skipped)(?: on '.+')? \(([^)]+)\)/u,
+ );
if (!match) {
return null;
}