From 46a722810d0ae314e32f7eb940bea60fb3e8ffd3 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 8 Apr 2026 22:01:27 +0100 Subject: [PATCH 01/15] refactor: remove deprecated logging tools and manifests From d922fd7fe35e8e7afb0f24a4d93def9e94f38d1e Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 8 Apr 2026 22:01:56 +0100 Subject: [PATCH 02/15] refactor: add pipeline event types, xcodebuild parsers, and event builders --- src/types/pipeline-events.ts | 199 ++++++++ .../swift-testing-line-parsers.test.ts | 157 +++++++ .../__tests__/xcodebuild-event-parser.test.ts | 292 ++++++++++++ .../__tests__/xcodebuild-line-parsers.test.ts | 39 ++ .../__tests__/xcodebuild-run-state.test.ts | 382 ++++++++++++++++ src/utils/swift-testing-event-parser.ts | 201 +++++++++ src/utils/swift-testing-line-parsers.ts | 154 +++++++ src/utils/tool-event-builders.ts | 88 ++++ src/utils/xcodebuild-error-utils.ts | 54 +++ src/utils/xcodebuild-event-parser.ts | 427 ++++++++++++++++++ src/utils/xcodebuild-line-parsers.ts | 160 +++++++ src/utils/xcodebuild-run-state.ts | 239 ++++++++++ 12 files changed, 2392 insertions(+) create mode 100644 src/types/pipeline-events.ts create mode 100644 src/utils/__tests__/swift-testing-line-parsers.test.ts create mode 100644 src/utils/__tests__/xcodebuild-event-parser.test.ts create mode 100644 src/utils/__tests__/xcodebuild-line-parsers.test.ts create mode 100644 src/utils/__tests__/xcodebuild-run-state.test.ts create mode 100644 src/utils/swift-testing-event-parser.ts create mode 100644 src/utils/swift-testing-line-parsers.ts create mode 100644 src/utils/tool-event-builders.ts create mode 100644 src/utils/xcodebuild-error-utils.ts create mode 100644 src/utils/xcodebuild-event-parser.ts create mode 100644 src/utils/xcodebuild-line-parsers.ts create mode 100644 src/utils/xcodebuild-run-state.ts diff --git a/src/types/pipeline-events.ts b/src/types/pipeline-events.ts new file mode 100644 index 00000000..5dbc424d --- /dev/null +++ b/src/types/pipeline-events.ts @@ -0,0 +1,199 @@ +export type XcodebuildOperation = 'BUILD' | 'TEST'; + +export type XcodebuildStage = + | 'RESOLVING_PACKAGES' + | 'COMPILING' + | 'LINKING' + | 'PREPARING_TESTS' + | 'RUN_TESTS' + | 'ARCHIVING' + | 'COMPLETED'; + +export const STAGE_RANK: Record = { + RESOLVING_PACKAGES: 0, + COMPILING: 1, + LINKING: 2, + PREPARING_TESTS: 3, + RUN_TESTS: 4, + ARCHIVING: 5, + COMPLETED: 6, +}; + +interface BaseEvent { + timestamp: string; +} + +// --- Canonical types (used by ALL tools) --- + +export interface HeaderEvent extends BaseEvent { + type: 'header'; + operation: string; + params: Array<{ label: string; value: string }>; +} + +export interface StatusLineEvent extends BaseEvent { + type: 'status-line'; + level: 'success' | 'error' | 'info' | 'warning'; + message: string; +} + +export interface SummaryEvent extends BaseEvent { + type: 'summary'; + operation?: string; + status: 'SUCCEEDED' | 'FAILED'; + totalTests?: number; + passedTests?: number; + failedTests?: number; + skippedTests?: number; + durationMs?: number; +} + +export interface SectionEvent extends BaseEvent { + type: 'section'; + title: string; + icon?: 'red-circle' | 'yellow-circle' | 'green-circle' | 'checkmark' | 'cross' | 'info'; + lines: string[]; + blankLineAfterTitle?: boolean; +} + +export interface DetailTreeEvent extends BaseEvent { + type: 'detail-tree'; + items: Array<{ label: string; value: string }>; +} + +export interface TableEvent extends BaseEvent { + type: 'table'; + heading?: string; + columns: string[]; + rows: Array>; +} + +export interface FileRefEvent extends BaseEvent { + type: 'file-ref'; + label?: string; + path: string; +} + +export interface NextStepsEvent extends BaseEvent { + type: 'next-steps'; + steps: Array<{ + label?: string; + tool?: string; + workflow?: string; + cliTool?: string; + params?: Record; + }>; + runtime?: 'cli' | 'daemon' | 'mcp'; +} + +// --- Xcodebuild-specific types --- + +export interface BuildStageEvent extends BaseEvent { + type: 'build-stage'; + operation: XcodebuildOperation; + stage: XcodebuildStage; + message: string; +} + +export interface CompilerWarningEvent extends BaseEvent { + type: 'compiler-warning'; + operation: XcodebuildOperation; + message: string; + location?: string; + rawLine: string; +} + +export interface CompilerErrorEvent extends BaseEvent { + type: 'compiler-error'; + operation: XcodebuildOperation; + message: string; + location?: string; + rawLine: string; +} + +export interface TestDiscoveryEvent extends BaseEvent { + type: 'test-discovery'; + operation: 'TEST'; + total: number; + tests: string[]; + truncated: boolean; +} + +export interface TestProgressEvent extends BaseEvent { + type: 'test-progress'; + operation: 'TEST'; + completed: number; + failed: number; + skipped: number; +} + +export interface TestFailureEvent extends BaseEvent { + type: 'test-failure'; + operation: 'TEST'; + target?: string; + suite?: string; + test?: string; + message: string; + location?: string; + durationMs?: number; +} + +// --- Union types --- + +/** Generic UI/output events usable by any tool */ +export type CommonPipelineEvent = + | HeaderEvent + | StatusLineEvent + | SummaryEvent + | SectionEvent + | DetailTreeEvent + | TableEvent + | FileRefEvent + | NextStepsEvent; + +/** Build/test-specific events (xcodebuild, swift build/test/run) */ +export type BuildTestPipelineEvent = + | BuildStageEvent + | CompilerWarningEvent + | CompilerErrorEvent + | TestDiscoveryEvent + | TestProgressEvent + | TestFailureEvent; + +export type PipelineEvent = CommonPipelineEvent | BuildTestPipelineEvent; + +// --- Build-run notice types (used by xcodebuild pipeline internals) --- + +export type NoticeLevel = 'info' | 'success' | 'warning'; + +export type BuildRunStepName = + | 'resolve-app-path' + | 'resolve-simulator' + | 'boot-simulator' + | 'install-app' + | 'extract-bundle-id' + | 'launch-app'; + +export type BuildRunStepStatus = 'started' | 'succeeded'; + +export interface BuildRunStepNoticeData { + step: BuildRunStepName; + status: BuildRunStepStatus; + appPath?: string; +} + +export interface BuildRunResultNoticeData { + scheme: string; + platform: string; + target: string; + appPath: string; + launchState: 'requested' | 'running'; + bundleId?: string; + appId?: string; + processId?: number; + buildLogPath?: string; + runtimeLogPath?: string; + osLogPath?: string; +} + +export type NoticeCode = 'build-run-step' | 'build-run-result'; diff --git a/src/utils/__tests__/swift-testing-line-parsers.test.ts b/src/utils/__tests__/swift-testing-line-parsers.test.ts new file mode 100644 index 00000000..07908fe2 --- /dev/null +++ b/src/utils/__tests__/swift-testing-line-parsers.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { + parseSwiftTestingResultLine, + parseSwiftTestingIssueLine, + parseSwiftTestingRunSummary, + parseSwiftTestingContinuationLine, + parseXcodebuildSwiftTestingLine, +} from '../swift-testing-line-parsers.ts'; + +describe('Swift Testing line parsers', () => { + describe('parseSwiftTestingResultLine', () => { + it('should parse a passed test', () => { + const result = parseSwiftTestingResultLine( + '✔ Test "Basic math operations" passed after 0.001 seconds.', + ); + expect(result).toEqual({ + status: 'passed', + rawName: 'Basic math operations', + testName: 'Basic math operations', + durationText: '0.001s', + }); + }); + + it('should parse a failed test', () => { + const result = parseSwiftTestingResultLine( + '✘ Test "Expected failure" failed after 0.001 seconds with 1 issue.', + ); + expect(result).toEqual({ + status: 'failed', + rawName: 'Expected failure', + testName: 'Expected failure', + durationText: '0.001s', + }); + }); + + it('should parse a skipped test', () => { + const result = parseSwiftTestingResultLine('◇ Test "Disabled test" skipped.'); + expect(result).toEqual({ + status: 'skipped', + rawName: 'Disabled test', + testName: 'Disabled test', + }); + }); + + it('should return null for non-matching lines', () => { + expect(parseSwiftTestingResultLine('◇ Test "Foo" started.')).toBeNull(); + expect(parseSwiftTestingResultLine('random text')).toBeNull(); + }); + }); + + describe('parseSwiftTestingIssueLine', () => { + it('should parse an issue with location', () => { + const result = parseSwiftTestingIssueLine( + '✘ Test "Expected failure" recorded an issue at SimpleTests.swift:48:5: Expectation failed: true == false', + ); + expect(result).toEqual({ + rawTestName: 'Expected failure', + testName: 'Expected failure', + location: 'SimpleTests.swift:48', + message: 'Expectation failed: true == false', + }); + }); + + it('should parse an issue without location', () => { + const result = parseSwiftTestingIssueLine( + '✘ Test "Some test" recorded an issue: Something went wrong', + ); + expect(result).toEqual({ + rawTestName: 'Some test', + testName: 'Some test', + message: 'Something went wrong', + }); + }); + + it('should return null for non-matching lines', () => { + expect(parseSwiftTestingIssueLine('✘ Test "Foo" failed after 0.001 seconds')).toBeNull(); + }); + }); + + describe('parseSwiftTestingRunSummary', () => { + it('should parse a failed run summary', () => { + const result = parseSwiftTestingRunSummary( + '✘ Test run with 6 tests in 0 suites failed after 0.001 seconds with 1 issue.', + ); + expect(result).toEqual({ + executed: 6, + failed: 1, + durationText: '0.001s', + }); + }); + + it('should parse a passed run summary', () => { + const result = parseSwiftTestingRunSummary( + '✔ Test run with 5 tests in 2 suites passed after 0.003 seconds.', + ); + expect(result).toEqual({ + executed: 5, + failed: 0, + durationText: '0.003s', + }); + }); + + it('should return null for non-matching lines', () => { + expect(parseSwiftTestingRunSummary('random text')).toBeNull(); + }); + }); + + describe('parseSwiftTestingContinuationLine', () => { + it('should parse a continuation line', () => { + expect(parseSwiftTestingContinuationLine('↳ This test should fail')).toBe( + 'This test should fail', + ); + }); + + it('should return null for non-continuation lines', () => { + 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__/xcodebuild-event-parser.test.ts b/src/utils/__tests__/xcodebuild-event-parser.test.ts new file mode 100644 index 00000000..56ef0b27 --- /dev/null +++ b/src/utils/__tests__/xcodebuild-event-parser.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from 'vitest'; +import { createXcodebuildEventParser } from '../xcodebuild-event-parser.ts'; +import type { PipelineEvent } from '../../types/pipeline-events.ts'; + +function collectEvents( + operation: 'BUILD' | 'TEST', + lines: { source: 'stdout' | 'stderr'; text: string }[], +): PipelineEvent[] { + const events: PipelineEvent[] = []; + const parser = createXcodebuildEventParser({ + operation, + onEvent: (event) => events.push(event), + }); + + for (const { source, text } of lines) { + if (source === 'stdout') { + parser.onStdout(text); + } else { + parser.onStderr(text); + } + } + + parser.flush(); + return events; +} + +describe('xcodebuild-event-parser', () => { + it('emits status events for package resolution', () => { + const events = collectEvents('TEST', [{ source: 'stdout', text: 'Resolve Package Graph\n' }]); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'build-stage', + operation: 'TEST', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + }); + + it('emits status events for compile patterns', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: 'CompileSwift normal arm64 /tmp/App.swift\n' }, + ]); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'build-stage', + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + }); + + it('emits status events for linking', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: 'Ld /Build/Products/Debug/MyApp.app/MyApp normal arm64\n' }, + ]); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'build-stage', + operation: 'BUILD', + stage: 'LINKING', + message: 'Linking', + }); + }); + + it('emits status events for test start', () => { + const events = collectEvents('TEST', [{ source: 'stdout', text: 'Testing started\n' }]); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: '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" }, + { source: 'stdout', text: "Test Case '-[Suite testB]' failed (0.002 seconds)\n" }, + { source: 'stdout', text: "Test Case '-[Suite testC]' passed (0.003 seconds)\n" }, + ]); + + const progressEvents = events.filter((e) => e.type === '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: 0 }); + }); + + it('emits test-progress from totals line', () => { + const events = collectEvents('TEST', [ + { + source: 'stdout', + text: 'Executed 5 tests, with 2 failures (0 unexpected) in 1.234 (1.235) seconds\n', + }, + ]); + + const progressEvents = events.filter((e) => e.type === 'test-progress'); + expect(progressEvents).toHaveLength(1); + expect(progressEvents[0]).toMatchObject({ completed: 5, failed: 2 }); + }); + + it('emits test-failure events from diagnostics', () => { + const events = collectEvents('TEST', [ + { + source: 'stderr', + text: '/tmp/Test.swift:52: error: -[Suite testB] : XCTAssertEqual failed: ("0") is not equal to ("1")\n', + }, + ]); + + const failures = events.filter((e) => e.type === 'test-failure'); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ + type: 'test-failure', + suite: 'Suite', + test: 'testB', + location: '/tmp/Test.swift:52', + message: 'XCTAssertEqual failed: ("0") is not equal to ("1")', + }); + }); + + it('attaches failure duration when the diagnostic and failed test case lines both appear', () => { + const events = collectEvents('TEST', [ + { + source: 'stderr', + text: '/tmp/Test.swift:52: error: -[Suite testB] : XCTAssertEqual failed: ("0") is not equal to ("1")\n', + }, + { source: 'stdout', text: "Test Case '-[Suite testB]' failed (0.002 seconds)\n" }, + ]); + + const failures = events.filter((e) => e.type === 'test-failure'); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ + type: 'test-failure', + suite: 'Suite', + test: 'testB', + location: '/tmp/Test.swift:52', + message: 'XCTAssertEqual failed: ("0") is not equal to ("1")', + durationMs: 2, + }); + }); + + it('emits error events for build errors', () => { + const events = collectEvents('BUILD', [ + { + source: 'stdout', + text: "/tmp/App.swift:8:17: error: cannot convert value of type 'String' to specified type 'Int'\n", + }, + ]); + + const errors = events.filter((e) => e.type === 'compiler-error'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + type: 'compiler-error', + location: '/tmp/App.swift:8', + message: "cannot convert value of type 'String' to specified type 'Int'", + }); + }); + + it('emits error events for non-location build errors', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: 'error: emit-module command failed with exit code 1\n' }, + ]); + + const errors = events.filter((e) => e.type === 'compiler-error'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + type: 'compiler-error', + message: 'emit-module command failed with exit code 1', + }); + }); + + it('accumulates indented continuation lines into the preceding error', () => { + const events = collectEvents('BUILD', [ + { + source: 'stderr', + text: 'xcodebuild: error: Unable to find a device matching the provided destination specifier:\n', + }, + { source: 'stderr', text: '\t\t{ platform:iOS Simulator, name:iPhone 22, OS:latest }\n' }, + { source: 'stderr', text: '\n' }, + ]); + + const errors = events.filter((e) => e.type === 'compiler-error'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + type: 'compiler-error', + message: + 'Unable to find a device matching the provided destination specifier:\n{ platform:iOS Simulator, name:iPhone 22, OS:latest }', + }); + }); + + it('emits warning events', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: '/tmp/App.swift:10:5: warning: variable unused\n' }, + ]); + + const warnings = events.filter((e) => e.type === 'compiler-warning'); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatchObject({ + type: 'compiler-warning', + location: '/tmp/App.swift:10', + message: 'variable unused', + }); + }); + + it('emits warning events for prefixed warnings', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: 'ld: warning: directory not found for option\n' }, + ]); + + const warnings = events.filter((e) => e.type === 'compiler-warning'); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatchObject({ + type: 'compiler-warning', + message: 'directory not found for option', + }); + }); + + it('handles split chunks across buffer boundaries', () => { + const events: PipelineEvent[] = []; + const parser = createXcodebuildEventParser({ + operation: 'TEST', + onEvent: (event) => events.push(event), + }); + + parser.onStdout('Resolve Pack'); + parser.onStdout('age Graph\n'); + parser.flush(); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: 'build-stage', stage: 'RESOLVING_PACKAGES' }); + }); + + it('attaches swift-testing failure duration when the issue and failed result lines both appear', () => { + const events = collectEvents('TEST', [ + { + source: 'stdout', + text: '✘ Test "IntentionalFailureSuite/test" recorded an issue at /tmp/SimpleTests.swift:48:5: Expectation failed: true == false\n', + }, + { + source: 'stdout', + text: '✘ Test "IntentionalFailureSuite/test" failed after 0.003 seconds with 1 issue.\n', + }, + ]); + + const failures = events.filter((e) => e.type === 'test-failure'); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ + type: 'test-failure', + suite: 'IntentionalFailureSuite', + test: 'test', + location: '/tmp/SimpleTests.swift:48', + message: 'Expectation failed: true == false', + durationMs: 3, + }); + }); + + it('processes full test lifecycle', () => { + const events = collectEvents('TEST', [ + { source: 'stdout', text: 'Resolve Package Graph\n' }, + { source: 'stdout', text: 'CompileSwift normal arm64 /tmp/App.swift\n' }, + { 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: 'stderr', + text: '/tmp/Test.swift:52: error: -[Suite testB] : XCTAssertEqual failed: ("0") is not equal to ("1")\n', + }, + { + source: 'stdout', + text: 'Executed 2 tests, with 1 failures (0 unexpected) in 0.123 (0.124) seconds\n', + }, + ]); + + const types = events.map((e) => e.type); + expect(types).toContain('build-stage'); + expect(types).toContain('test-progress'); + expect(types).toContain('test-failure'); + }); + + it('skips Test Suite and Testing started noise lines without emitting events', () => { + const events = collectEvents('TEST', [ + { source: 'stdout', text: "Test Suite 'All tests' started at 2025-01-01 00:00:00.000.\n" }, + { source: 'stdout', text: "Test Suite 'All tests' passed at 2025-01-01 00:00:01.000.\n" }, + ]); + + // Test Suite 'All tests' started triggers RUN_TESTS status; 'passed' is noise + const statusEvents = events.filter((e) => e.type === 'build-stage'); + expect(statusEvents.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/src/utils/__tests__/xcodebuild-line-parsers.test.ts b/src/utils/__tests__/xcodebuild-line-parsers.test.ts new file mode 100644 index 00000000..1d2c3cd3 --- /dev/null +++ b/src/utils/__tests__/xcodebuild-line-parsers.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { parseDurationMs, parseRawTestName } from '../xcodebuild-line-parsers.ts'; + +describe('parseDurationMs', () => { + it('parses xcodebuild-style seconds text into milliseconds', () => { + expect(parseDurationMs('0.002 seconds')).toBe(2); + expect(parseDurationMs('1.234s')).toBe(1234); + }); + + it('returns undefined for unparseable duration text', () => { + expect(parseDurationMs('unknown')).toBeUndefined(); + expect(parseDurationMs()).toBeUndefined(); + }); +}); + +describe('parseRawTestName', () => { + it('normalizes module-prefixed slash test names', () => { + expect( + parseRawTestName('CalculatorAppTests.CalculatorAppTests/testCalculatorServiceFailure'), + ).toEqual({ + suiteName: 'CalculatorAppTests', + testName: 'testCalculatorServiceFailure', + }); + }); + + it('normalizes module-prefixed objective-c style test names', () => { + expect(parseRawTestName('-[CalculatorAppTests.IntentionalFailureTests test]')).toEqual({ + suiteName: 'IntentionalFailureTests', + testName: 'test', + }); + }); + + it('keeps multi-segment slash suite names for swift-testing output', () => { + expect(parseRawTestName('TestLibTests/IntentionalFailureSuite/test')).toEqual({ + suiteName: 'TestLibTests/IntentionalFailureSuite', + testName: 'test', + }); + }); +}); diff --git a/src/utils/__tests__/xcodebuild-run-state.test.ts b/src/utils/__tests__/xcodebuild-run-state.test.ts new file mode 100644 index 00000000..6d0e0f7d --- /dev/null +++ b/src/utils/__tests__/xcodebuild-run-state.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, it } from 'vitest'; +import { createXcodebuildRunState } from '../xcodebuild-run-state.ts'; +import type { PipelineEvent } from '../../types/pipeline-events.ts'; +import { STAGE_RANK } from '../../types/pipeline-events.ts'; + +function ts(): string { + return '2025-01-01T00:00:00.000Z'; +} + +describe('xcodebuild-run-state', () => { + it('accepts status events and tracks milestones in order', () => { + const forwarded: PipelineEvent[] = []; + const state = createXcodebuildRunState({ + operation: 'TEST', + onEvent: (e) => forwarded.push(e), + }); + + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'TEST', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'TEST', + stage: 'COMPILING', + message: 'Compiling', + }); + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'TEST', + stage: 'RUN_TESTS', + message: 'Running tests', + }); + + const snap = state.snapshot(); + expect(snap.milestones).toHaveLength(3); + expect(snap.milestones.map((m) => m.stage)).toEqual([ + 'RESOLVING_PACKAGES', + 'COMPILING', + 'RUN_TESTS', + ]); + expect(snap.currentStage).toBe('RUN_TESTS'); + expect(forwarded).toHaveLength(3); + }); + + it('deduplicates milestones at or below current rank', () => { + const state = createXcodebuildRunState({ operation: 'BUILD' }); + + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'BUILD', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + // Duplicate: should be ignored + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'BUILD', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'BUILD', + stage: 'COMPILING', + message: 'Compiling', + }); + + const snap = state.snapshot(); + expect(snap.milestones).toHaveLength(2); + }); + + it('respects minimumStage for multi-phase continuation', () => { + const state = createXcodebuildRunState({ + operation: 'TEST', + minimumStage: 'COMPILING', + }); + + // These should be suppressed because they're at or below COMPILING rank + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'TEST', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'TEST', + stage: 'COMPILING', + message: 'Compiling', + }); + // This should be accepted + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'TEST', + stage: 'RUN_TESTS', + message: 'Running tests', + }); + + const snap = state.snapshot(); + expect(snap.milestones).toHaveLength(1); + expect(snap.milestones[0].stage).toBe('RUN_TESTS'); + }); + + it('deduplicates error diagnostics by location+message', () => { + const state = createXcodebuildRunState({ operation: 'BUILD' }); + + const error: PipelineEvent = { + type: 'compiler-error', + timestamp: ts(), + operation: 'BUILD', + message: 'type mismatch', + location: '/tmp/App.swift:8', + rawLine: '/tmp/App.swift:8:17: error: type mismatch', + }; + + state.push(error); + state.push(error); + + const snap = state.snapshot(); + expect(snap.errors).toHaveLength(1); + }); + + it('deduplicates test failures by location+message', () => { + const state = createXcodebuildRunState({ operation: 'TEST' }); + + const failure: PipelineEvent = { + type: 'test-failure', + timestamp: ts(), + operation: 'TEST', + suite: 'Suite', + test: 'testA', + message: 'assertion failed', + location: '/tmp/Test.swift:10', + }; + + state.push(failure); + state.push(failure); + + const snap = state.snapshot(); + expect(snap.testFailures).toHaveLength(1); + }); + + it('deduplicates test failures when xcresult and live parsing disagree on suite/test naming', () => { + const state = createXcodebuildRunState({ operation: 'TEST' }); + + state.push({ + type: 'test-failure', + timestamp: ts(), + operation: 'TEST', + suite: 'CalculatorAppTests.CalculatorAppTests', + test: 'testCalculatorServiceFailure', + message: 'XCTAssertEqual failed', + location: '/tmp/CalculatorAppTests.swift:52', + }); + state.push({ + type: 'test-failure', + timestamp: ts(), + operation: 'TEST', + test: 'testCalculatorServiceFailure()', + message: 'XCTAssertEqual failed', + location: 'CalculatorAppTests.swift:52', + }); + + const snap = state.snapshot(); + expect(snap.testFailures).toHaveLength(1); + }); + + it('deduplicates warnings by location+message', () => { + const state = createXcodebuildRunState({ operation: 'BUILD' }); + + const warning: PipelineEvent = { + type: 'compiler-warning', + timestamp: ts(), + operation: 'BUILD', + message: 'unused variable', + location: '/tmp/App.swift:5', + rawLine: '/tmp/App.swift:5: warning: unused variable', + }; + + state.push(warning); + state.push(warning); + + const snap = state.snapshot(); + expect(snap.warnings).toHaveLength(1); + }); + + it('tracks test counts from test-progress events', () => { + const state = createXcodebuildRunState({ operation: 'TEST' }); + + state.push({ + type: 'test-progress', + timestamp: ts(), + operation: 'TEST', + completed: 1, + failed: 0, + skipped: 0, + }); + state.push({ + type: 'test-progress', + timestamp: ts(), + operation: 'TEST', + completed: 2, + failed: 1, + skipped: 0, + }); + state.push({ + type: 'test-progress', + timestamp: ts(), + operation: 'TEST', + completed: 3, + failed: 1, + skipped: 1, + }); + + const snap = state.snapshot(); + expect(snap.completedTests).toBe(3); + expect(snap.failedTests).toBe(1); + expect(snap.skippedTests).toBe(1); + }); + + it('auto-inserts RUN_TESTS milestone on first test-progress', () => { + const forwarded: PipelineEvent[] = []; + const state = createXcodebuildRunState({ + operation: 'TEST', + onEvent: (e) => forwarded.push(e), + }); + + state.push({ + type: 'test-progress', + timestamp: ts(), + operation: 'TEST', + completed: 1, + failed: 0, + skipped: 0, + }); + + const snap = state.snapshot(); + expect(snap.milestones).toHaveLength(1); + expect(snap.milestones[0].stage).toBe('RUN_TESTS'); + // RUN_TESTS status + test-progress both forwarded + expect(forwarded).toHaveLength(2); + }); + + it('finalize emits summary event and sets final status', () => { + const forwarded: PipelineEvent[] = []; + const state = createXcodebuildRunState({ + operation: 'TEST', + onEvent: (e) => forwarded.push(e), + }); + + state.push({ + type: 'test-progress', + timestamp: ts(), + operation: 'TEST', + completed: 5, + failed: 2, + skipped: 0, + }); + + const finalState = state.finalize(false, 1234); + + expect(finalState.finalStatus).toBe('FAILED'); + expect(finalState.wallClockDurationMs).toBe(1234); + + const summaryEvents = finalState.events.filter((e) => e.type === 'summary'); + expect(summaryEvents).toHaveLength(1); + + const summary = summaryEvents[0]!; + if (summary.type === 'summary') { + expect(summary.status).toBe('FAILED'); + expect(summary.totalTests).toBe(5); + expect(summary.failedTests).toBe(2); + expect(summary.passedTests).toBe(3); + expect(summary.durationMs).toBe(1234); + } + }); + + it('reconciles summary counts with explicit test failures', () => { + const state = createXcodebuildRunState({ operation: 'TEST' }); + + state.push({ + type: 'test-progress', + timestamp: ts(), + operation: 'TEST', + completed: 6, + failed: 1, + skipped: 0, + }); + state.push({ + type: 'test-failure', + timestamp: ts(), + operation: 'TEST', + suite: 'CalculatorAppTests', + test: 'testCalculatorServiceFailure', + message: 'XCTAssertEqual failed', + location: '/tmp/SimpleTests.swift:49', + }); + state.push({ + type: 'test-failure', + timestamp: ts(), + operation: 'TEST', + test: 'test', + message: 'Expectation failed: Bool(false)', + location: '/tmp/SimpleTests.swift:57', + }); + + const finalState = state.finalize(false, 1234); + const summary = finalState.events.find((event) => event.type === 'summary'); + + expect(summary).toBeDefined(); + if (summary?.type === 'summary') { + expect(summary.totalTests).toBe(7); + expect(summary.passedTests).toBe(5); + expect(summary.failedTests).toBe(2); + expect(summary.skippedTests).toBe(0); + } + }); + + it('highestStageRank returns correct rank for multi-phase handoff', () => { + const state = createXcodebuildRunState({ operation: 'TEST' }); + + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'TEST', + stage: 'RESOLVING_PACKAGES', + message: 'Resolving packages', + }); + state.push({ + type: 'build-stage', + timestamp: ts(), + operation: 'TEST', + stage: 'COMPILING', + message: 'Compiling', + }); + + expect(state.highestStageRank()).toBe(STAGE_RANK.COMPILING); + }); + + it('passes through header and next-steps events', () => { + const forwarded: PipelineEvent[] = []; + const state = createXcodebuildRunState({ + operation: 'TEST', + onEvent: (e) => forwarded.push(e), + }); + + state.push({ + type: 'header', + timestamp: ts(), + operation: 'Test', + params: [], + }); + state.push({ + type: 'next-steps', + timestamp: ts(), + steps: [{ tool: 'foo' }], + }); + + expect(forwarded).toHaveLength(2); + expect(forwarded[0].type).toBe('header'); + expect(forwarded[1].type).toBe('next-steps'); + }); +}); diff --git a/src/utils/swift-testing-event-parser.ts b/src/utils/swift-testing-event-parser.ts new file mode 100644 index 00000000..9159d1b1 --- /dev/null +++ b/src/utils/swift-testing-event-parser.ts @@ -0,0 +1,201 @@ +import type { PipelineEvent } from '../types/pipeline-events.ts'; +import { + parseSwiftTestingResultLine, + parseSwiftTestingIssueLine, + parseSwiftTestingRunSummary, + parseSwiftTestingContinuationLine, +} from './swift-testing-line-parsers.ts'; +import { + parseTestCaseLine, + parseTotalsLine, + parseFailureDiagnostic, +} from './xcodebuild-line-parsers.ts'; + +export interface SwiftTestingEventParser { + onStdout(chunk: string): void; + onStderr(chunk: string): void; + flush(): void; +} + +export interface SwiftTestingEventParserOptions { + onEvent: (event: PipelineEvent) => void; +} + +function now(): string { + return new Date().toISOString(); +} + +export function createSwiftTestingEventParser( + options: SwiftTestingEventParserOptions, +): SwiftTestingEventParser { + const { onEvent } = options; + + let stdoutBuffer = ''; + let stderrBuffer = ''; + let completedCount = 0; + let failedCount = 0; + let skippedCount = 0; + + let lastIssueDiagnostic: { + testName?: string; + message: string; + location?: string; + } | null = null; + + function flushPendingIssue(): void { + if (!lastIssueDiagnostic) { + return; + } + onEvent({ + type: 'test-failure', + timestamp: now(), + operation: 'TEST', + test: lastIssueDiagnostic.testName, + message: lastIssueDiagnostic.message, + location: lastIssueDiagnostic.location, + }); + lastIssueDiagnostic = null; + } + + function emitTestProgress(): void { + onEvent({ + type: 'test-progress', + timestamp: now(), + operation: 'TEST', + completed: completedCount, + failed: failedCount, + skipped: skippedCount, + }); + } + + function processLine(rawLine: string): void { + const line = rawLine.trim(); + if (!line) { + flushPendingIssue(); + return; + } + + // Swift Testing continuation line (↳) appends context to the pending issue + const continuation = parseSwiftTestingContinuationLine(line); + if (continuation && lastIssueDiagnostic) { + lastIssueDiagnostic.message += `\n${continuation}`; + return; + } + + flushPendingIssue(); + + // Swift Testing issue line: ✘ Test "Name" recorded an issue at file:line:col: message + const issue = parseSwiftTestingIssueLine(line); + if (issue) { + lastIssueDiagnostic = { + testName: issue.testName, + message: issue.message, + location: issue.location, + }; + return; + } + + // Swift Testing result line: ✔/✘/◇ Test "Name" passed/failed/skipped + const stResult = parseSwiftTestingResultLine(line); + if (stResult) { + completedCount += 1; + if (stResult.status === 'failed') { + failedCount += 1; + } + if (stResult.status === 'skipped') { + skippedCount += 1; + } + emitTestProgress(); + return; + } + + // Swift Testing run summary + const stSummary = parseSwiftTestingRunSummary(line); + if (stSummary) { + completedCount = stSummary.executed; + failedCount = stSummary.failed; + emitTestProgress(); + return; + } + + // XCTest: Test Case '...' passed/failed (for mixed output from `swift test`) + const xcTestCase = parseTestCaseLine(line); + if (xcTestCase) { + completedCount += 1; + if (xcTestCase.status === 'failed') { + failedCount += 1; + } + if (xcTestCase.status === 'skipped') { + skippedCount += 1; + } + emitTestProgress(); + return; + } + + // XCTest totals: Executed N tests, with N failures + const xcTotals = parseTotalsLine(line); + if (xcTotals) { + completedCount = xcTotals.executed; + failedCount = xcTotals.failed; + emitTestProgress(); + return; + } + + // XCTest failure diagnostic: file:line: error: -[Suite test] : message + const xcFailure = parseFailureDiagnostic(line); + if (xcFailure) { + onEvent({ + type: 'test-failure', + timestamp: now(), + operation: 'TEST', + suite: xcFailure.suiteName, + test: xcFailure.testName, + message: xcFailure.message, + location: xcFailure.location, + }); + return; + } + + // Detect test run start + if (/^[◇] Test run started/u.test(line) || /^Testing started$/u.test(line)) { + onEvent({ + type: 'build-stage', + timestamp: now(), + operation: 'TEST', + stage: 'RUN_TESTS', + message: 'Running tests', + }); + return; + } + } + + function drainLines(buffer: string, chunk: string): string { + const combined = buffer + chunk; + const lines = combined.split(/\r?\n/u); + const remainder = lines.pop() ?? ''; + for (const line of lines) { + processLine(line); + } + return remainder; + } + + return { + onStdout(chunk: string): void { + stdoutBuffer = drainLines(stdoutBuffer, chunk); + }, + onStderr(chunk: string): void { + stderrBuffer = drainLines(stderrBuffer, chunk); + }, + flush(): void { + if (stdoutBuffer.trim()) { + processLine(stdoutBuffer); + } + if (stderrBuffer.trim()) { + processLine(stderrBuffer); + } + flushPendingIssue(); + stdoutBuffer = ''; + stderrBuffer = ''; + }, + }; +} diff --git a/src/utils/swift-testing-line-parsers.ts b/src/utils/swift-testing-line-parsers.ts new file mode 100644 index 00000000..08288aaf --- /dev/null +++ b/src/utils/swift-testing-line-parsers.ts @@ -0,0 +1,154 @@ +import { + type ParsedTestCase, + type ParsedFailureDiagnostic, + type ParsedTotals, + parseRawTestName, +} from './xcodebuild-line-parsers.ts'; + +/** + * Parse a Swift Testing result line (passed/failed/skipped). + * + * Matches: + * ✔ Test "Name" passed after 0.001 seconds. + * ✘ Test "Name" failed after 0.001 seconds with 1 issue. + * ✘ Test "Name" failed after 0.001 seconds with 3 issues. + * ◇ Test "Name" skipped. + */ +export function parseSwiftTestingResultLine(line: string): ParsedTestCase | null { + const passedMatch = line.match(/^[✔] Test "(.+)" passed after ([\d.]+) seconds\.?$/u); + if (passedMatch) { + const [, name, duration] = passedMatch; + const { suiteName, testName } = parseRawTestName(name); + return { + status: 'passed', + rawName: name, + suiteName, + testName, + durationText: `${duration}s`, + }; + } + + const failedMatch = line.match(/^[✘] Test "(.+)" failed after ([\d.]+) seconds/u); + if (failedMatch) { + const [, name, duration] = failedMatch; + const { suiteName, testName } = parseRawTestName(name); + return { + status: 'failed', + rawName: name, + suiteName, + testName, + durationText: `${duration}s`, + }; + } + + const skippedMatch = line.match(/^[◇] Test "(.+)" skipped/u); + if (skippedMatch) { + const rawName = skippedMatch[1]; + const { suiteName, testName } = parseRawTestName(rawName); + return { + status: 'skipped', + rawName, + suiteName, + testName, + }; + } + + return null; +} + +/** + * Parse a Swift Testing issue line. + * + * Matches: + * ✘ Test "Name" recorded an issue at File.swift:48:5: Expectation failed: ... + * ✘ Test "Name" recorded an issue: message + */ +export function parseSwiftTestingIssueLine(line: string): ParsedFailureDiagnostic | null { + const locationMatch = line.match(/^[✘] Test "(.+)" recorded an issue at (.+?):(\d+):\d+: (.+)$/u); + if (locationMatch) { + const [, rawTestName, filePath, lineNumber, message] = locationMatch; + const { suiteName, testName } = parseRawTestName(rawTestName); + return { + rawTestName, + suiteName, + testName, + location: `${filePath}:${lineNumber}`, + message, + }; + } + + const simpleMatch = line.match(/^[✘] Test "(.+)" recorded an issue: (.+)$/u); + if (simpleMatch) { + const [, rawTestName, message] = simpleMatch; + const { suiteName, testName } = parseRawTestName(rawTestName); + return { + rawTestName, + suiteName, + testName, + message, + }; + } + + return null; +} + +/** + * Parse a Swift Testing run summary line. + * + * Matches: + * ✔ Test run with 6 tests in 2 suites passed after 0.001 seconds. + * ✘ Test run with 6 tests in 0 suites failed after 0.001 seconds with 1 issue. + */ +export function parseSwiftTestingRunSummary(line: string): ParsedTotals | null { + const match = line.match( + /^[✔✘] Test run with (\d+) tests? in \d+ suites? (?:passed|failed) after ([\d.]+) seconds/u, + ); + if (!match) { + return null; + } + + const total = Number(match[1]); + const durationText = `${match[2]}s`; + + const issueMatch = line.match(/with (\d+) issues?/u); + const failed = issueMatch ? Number(issueMatch[1]) : 0; + + return { executed: total, failed, durationText }; +} + +/** + * Parse a Swift Testing continuation line (additional context for an issue). + * + * Matches: + * ↳ This test should fail... + */ +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/tool-event-builders.ts b/src/utils/tool-event-builders.ts new file mode 100644 index 00000000..5fbcf6f1 --- /dev/null +++ b/src/utils/tool-event-builders.ts @@ -0,0 +1,88 @@ +import type { + HeaderEvent, + SectionEvent, + StatusLineEvent, + FileRefEvent, + TableEvent, + DetailTreeEvent, + NextStepsEvent, +} from '../types/pipeline-events.ts'; + +function now(): string { + return new Date().toISOString(); +} + +export function header( + operation: string, + params?: Array<{ label: string; value: string }>, +): HeaderEvent { + return { + type: 'header', + timestamp: now(), + operation, + params: params ?? [], + }; +} + +export function section( + title: string, + lines: string[], + opts?: { icon?: SectionEvent['icon']; blankLineAfterTitle?: boolean }, +): SectionEvent { + return { + type: 'section', + timestamp: now(), + title, + icon: opts?.icon, + lines, + blankLineAfterTitle: opts?.blankLineAfterTitle, + }; +} + +export function statusLine(level: StatusLineEvent['level'], message: string): StatusLineEvent { + return { + type: 'status-line', + timestamp: now(), + level, + message, + }; +} + +export function fileRef(path: string, label?: string): FileRefEvent { + return { + type: 'file-ref', + timestamp: now(), + label, + path, + }; +} + +export function table( + columns: string[], + rows: Array>, + heading?: string, +): TableEvent { + return { + type: 'table', + timestamp: now(), + heading, + columns, + rows, + }; +} + +export function detailTree(items: Array<{ label: string; value: string }>): DetailTreeEvent { + return { + type: 'detail-tree', + timestamp: now(), + items, + }; +} + +export function nextSteps(steps: NextStepsEvent['steps']): NextStepsEvent { + return { + type: 'next-steps', + timestamp: now(), + steps, + }; +} diff --git a/src/utils/xcodebuild-error-utils.ts b/src/utils/xcodebuild-error-utils.ts new file mode 100644 index 00000000..bfc65244 --- /dev/null +++ b/src/utils/xcodebuild-error-utils.ts @@ -0,0 +1,54 @@ +const XCODEBUILD_ERROR_REGEX = /^xcodebuild:\s*error:\s*(.+)$/im; +const NOISE_PATTERNS = [ + /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+\s+xcodebuild\[/, + /^Writing error result bundle to\s/i, +]; + +function parseXcodebuildErrorMessage(rawOutput: string): string | null { + const match = XCODEBUILD_ERROR_REGEX.exec(rawOutput); + return match ? match[1].trim() : null; +} + +function cleanXcodebuildOutput(rawOutput: string): string { + return rawOutput + .split('\n') + .filter((line) => !NOISE_PATTERNS.some((pattern) => pattern.test(line.trim()))) + .join('\n') + .trim(); +} + +export function formatQueryError(rawOutput: string): string { + const parsed = parseXcodebuildErrorMessage(rawOutput); + if (parsed) { + return [`Errors (1):`, '', ` \u{2717} ${parsed}`].join('\n'); + } + + const cleaned = cleanXcodebuildOutput(rawOutput); + if (cleaned) { + const errorLines = cleaned.split('\n').filter((l) => l.trim()); + const count = errorLines.length; + const formatted = errorLines.map((l) => ` \u{2717} ${l.trim()}`).join('\n\n'); + return [`Errors (${count}):`, '', formatted].join('\n'); + } + + return ['Errors (1):', '', ' \u{2717} Unknown error'].join('\n'); +} + +export function formatQueryFailureSummary(): string { + return '\u{274C} Query failed.'; +} + +export function extractQueryErrorMessages(rawOutput: string): string[] { + const parsed = parseXcodebuildErrorMessage(rawOutput); + if (parsed) { + return [parsed]; + } + + const cleaned = cleanXcodebuildOutput(rawOutput); + if (cleaned) { + const errorLines = cleaned.split('\n').filter((l) => l.trim()); + if (errorLines.length > 0) return errorLines.map((l) => l.trim()); + } + + return ['Unknown error']; +} diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts new file mode 100644 index 00000000..420ef289 --- /dev/null +++ b/src/utils/xcodebuild-event-parser.ts @@ -0,0 +1,427 @@ +import type { + XcodebuildOperation, + PipelineEvent, + XcodebuildStage, +} from '../types/pipeline-events.ts'; +import { + packageResolutionPatterns, + compilePatterns, + linkPatterns, + parseTestCaseLine, + parseTotalsLine, + parseFailureDiagnostic, + parseBuildErrorDiagnostic, + parseDurationMs, +} from './xcodebuild-line-parsers.ts'; +import { + parseXcodebuildSwiftTestingLine, + parseSwiftTestingIssueLine, + parseSwiftTestingResultLine, + parseSwiftTestingRunSummary, + parseSwiftTestingContinuationLine, +} from './swift-testing-line-parsers.ts'; + +function resolveStageFromLine(line: string): XcodebuildStage | null { + if (packageResolutionPatterns.some((pattern) => pattern.test(line))) { + return 'RESOLVING_PACKAGES'; + } + if (compilePatterns.some((pattern) => pattern.test(line))) { + return 'COMPILING'; + } + if (linkPatterns.some((pattern) => pattern.test(line))) { + return 'LINKING'; + } + if ( + /^Testing started$/u.test(line) || + /^Test Suite .+ started/u.test(line) || + /^[◇] Test run started/u.test(line) + ) { + return 'RUN_TESTS'; + } + return null; +} + +const stageMessages: Record = { + RESOLVING_PACKAGES: 'Resolving packages', + COMPILING: 'Compiling', + LINKING: 'Linking', + PREPARING_TESTS: 'Preparing tests', + RUN_TESTS: 'Running tests', + ARCHIVING: 'Archiving', + COMPLETED: 'Completed', +}; + +function parseWarningLine(line: string): { location?: string; message: string } | null { + const locationMatch = line.match(/^(.*?):(\d+)(?::\d+)?:\s+warning:\s+(.+)$/u); + if (locationMatch) { + return { + location: `${locationMatch[1]}:${locationMatch[2]}`, + message: locationMatch[3], + }; + } + + const prefixedMatch = line.match(/^(?:[\w-]+:\s+)?warning:\s+(.+)$/iu); + if (prefixedMatch) { + return { message: prefixedMatch[1] }; + } + + return null; +} + +const IGNORED_NOISE_PATTERNS = [ + /^Command line invocation:$/u, + /^\s*\/Applications\/Xcode[^\s]+\/Contents\/Developer\/usr\/bin\/xcodebuild\b/u, + /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+\s+xcodebuild\[.+\]\s+Writing error result bundle to\s+/u, + /^Build settings from command line:$/u, + /^(?:COMPILER_INDEX_STORE_ENABLE|ONLY_ACTIVE_ARCH)\s*=\s*.+$/u, + /^Resolve Package Graph$/u, + /^Resolved source packages:$/u, + /^\s*[A-Za-z0-9_.-]+:\s+.+$/u, + /^--- xcodebuild: WARNING: Using the first of multiple matching destinations:$/u, + /^\{\s*platform:.+\}$/u, + /^(?:ComputePackagePrebuildTargetDependencyGraph|Prepare packages|CreateBuildRequest|SendProjectDescription|CreateBuildOperation|ComputeTargetDependencyGraph|GatherProvisioningInputs|CreateBuildDescription)$/u, + /^Target '.+' in project '.+' \(no dependencies\)$/u, + /^(?:Build description signature|Build description path):\s+.+$/u, + /^(?:ExecuteExternalTool|ClangStatCache|CopySwiftLibs|builtin-infoPlistUtility|builtin-swiftStdLibTool)\b/u, + /^cd\s+.+$/u, + /^\*\* BUILD SUCCEEDED \*\*$/u, +]; + +function isIgnoredNoiseLine(line: string): boolean { + return IGNORED_NOISE_PATTERNS.some((pattern) => pattern.test(line)); +} + +function now(): string { + return new Date().toISOString(); +} + +export interface EventParserOptions { + operation: XcodebuildOperation; + onEvent: (event: PipelineEvent) => void; + onUnrecognizedLine?: (line: string) => void; +} + +export interface XcodebuildEventParser { + onStdout(chunk: string): void; + onStderr(chunk: string): void; + flush(): void; + xcresultPath: string | null; +} + +export function createXcodebuildEventParser(options: EventParserOptions): XcodebuildEventParser { + const { operation, onEvent, onUnrecognizedLine } = options; + + let stdoutBuffer = ''; + let stderrBuffer = ''; + let completedCount = 0; + let failedCount = 0; + let skippedCount = 0; + let detectedXcresultPath: string | null = null; + + let pendingError: { + message: string; + location?: string; + rawLines: string[]; + timestamp: string; + } | null = null; + + const pendingFailureDiagnostics = new Map< + string, + Array<{ suiteName?: string; testName?: string; message: string; location?: string }> + >(); + const pendingFailureDurations = new Map(); + + function getFailureKey(suiteName?: string, testName?: string): string | null { + if (!suiteName && !testName) { + return null; + } + + return `${suiteName ?? ''}::${testName ?? ''}`.trim().toLowerCase(); + } + + function emitFailureEvent(failure: { + suiteName?: string; + testName?: string; + message: string; + location?: string; + durationMs?: number; + }): void { + if (operation !== 'TEST') { + return; + } + + onEvent({ + type: 'test-failure', + timestamp: now(), + operation: 'TEST', + suite: failure.suiteName, + test: failure.testName, + message: failure.message, + location: failure.location, + durationMs: failure.durationMs, + }); + } + + function queueFailureDiagnostic(failure: { + suiteName?: string; + testName?: string; + message: string; + location?: string; + }): void { + const key = getFailureKey(failure.suiteName, failure.testName); + if (!key) { + emitFailureEvent(failure); + return; + } + + const durationMs = pendingFailureDurations.get(key); + if (durationMs !== undefined) { + emitFailureEvent({ ...failure, durationMs }); + return; + } + + const queued = pendingFailureDiagnostics.get(key) ?? []; + queued.push(failure); + pendingFailureDiagnostics.set(key, queued); + } + + function flushQueuedFailureDiagnostics(): void { + for (const [key, failures] of pendingFailureDiagnostics.entries()) { + const durationMs = pendingFailureDurations.get(key); + for (const failure of failures) { + emitFailureEvent({ ...failure, durationMs }); + } + } + pendingFailureDiagnostics.clear(); + } + + function applyFailureDuration(suiteName?: string, testName?: string, durationMs?: number): void { + const key = getFailureKey(suiteName, testName); + if (!key || durationMs === undefined) { + return; + } + + pendingFailureDurations.set(key, durationMs); + const pendingFailures = pendingFailureDiagnostics.get(key); + if (!pendingFailures) { + return; + } + + for (const failure of pendingFailures) { + emitFailureEvent({ ...failure, durationMs }); + } + pendingFailureDiagnostics.delete(key); + } + + function emitTestProgress(): void { + if (operation !== 'TEST') { + return; + } + onEvent({ + type: 'test-progress', + timestamp: now(), + operation: 'TEST', + completed: completedCount, + failed: failedCount, + skipped: skippedCount, + }); + } + + function recordTestCaseResult(testCase: { + status: string; + suiteName?: string; + testName?: string; + durationText?: string; + }): void { + completedCount += 1; + if (testCase.status === 'failed') { + failedCount += 1; + applyFailureDuration( + testCase.suiteName, + testCase.testName, + parseDurationMs(testCase.durationText), + ); + } + if (testCase.status === 'skipped') { + skippedCount += 1; + } + emitTestProgress(); + } + + function flushPendingError(): void { + if (!pendingError) { + return; + } + onEvent({ + type: 'compiler-error', + timestamp: pendingError.timestamp, + operation, + message: pendingError.message, + location: pendingError.location, + rawLine: pendingError.rawLines.join('\n'), + }); + pendingError = null; + } + + function processLine(rawLine: string): void { + const line = rawLine.trim(); + if (!line) { + flushPendingError(); + return; + } + + // Swift Testing continuation line (↳) appends context to pending issue + const stContinuation = parseSwiftTestingContinuationLine(line); + if (stContinuation) { + const lastQueuedEntry = Array.from(pendingFailureDiagnostics.values()).at(-1)?.at(-1); + if (lastQueuedEntry) { + lastQueuedEntry.message += `\n${stContinuation}`; + return; + } + } + + if (pendingError && /^\s/u.test(rawLine)) { + pendingError.message += `\n${line}`; + pendingError.rawLines.push(rawLine); + return; + } + + flushPendingError(); + + const testCase = parseTestCaseLine(line); + if (testCase) { + recordTestCaseResult(testCase); + return; + } + + const totals = parseTotalsLine(line); + if (totals) { + completedCount = totals.executed; + failedCount = totals.failed; + emitTestProgress(); + return; + } + + const failureDiag = parseFailureDiagnostic(line); + if (failureDiag) { + queueFailureDiagnostic(failureDiag); + return; + } + + const xcodebuildST = parseXcodebuildSwiftTestingLine(line); + if (xcodebuildST) { + recordTestCaseResult(xcodebuildST); + return; + } + + // Swift Testing issue: ✘ Test "Name" recorded an issue at file:line:col: message + const stIssue = parseSwiftTestingIssueLine(line); + if (stIssue) { + queueFailureDiagnostic(stIssue); + return; + } + + const stResult = parseSwiftTestingResultLine(line); + if (stResult) { + recordTestCaseResult(stResult); + return; + } + + const stSummary = parseSwiftTestingRunSummary(line); + if (stSummary) { + completedCount = stSummary.executed; + failedCount = stSummary.failed; + emitTestProgress(); + return; + } + + const stage = resolveStageFromLine(line); + if (stage) { + onEvent({ + type: 'build-stage', + timestamp: now(), + operation, + stage, + message: stageMessages[stage], + }); + return; + } + + const buildError = parseBuildErrorDiagnostic(line); + if (buildError) { + pendingError = { + message: buildError.message, + location: buildError.location, + rawLines: [line], + timestamp: now(), + }; + return; + } + + const warning = parseWarningLine(line); + if (warning) { + onEvent({ + type: 'compiler-warning', + timestamp: now(), + operation, + message: warning.message, + location: warning.location, + rawLine: line, + }); + return; + } + + if (/^Test Suite /u.test(line)) { + return; + } + + if (isIgnoredNoiseLine(line)) { + return; + } + + // Capture xcresult path from xcodebuild output + const xcresultMatch = line.match(/^\s*(\S+\.xcresult)\s*$/u); + if (xcresultMatch) { + detectedXcresultPath = xcresultMatch[1]; + return; + } + + if (onUnrecognizedLine) { + onUnrecognizedLine(line); + } + } + + function drainLines(buffer: string, chunk: string): string { + const combined = buffer + chunk; + const lines = combined.split(/\r?\n/u); + const remainder = lines.pop() ?? ''; + for (const line of lines) { + processLine(line); + } + return remainder; + } + + return { + onStdout(chunk: string): void { + stdoutBuffer = drainLines(stdoutBuffer, chunk); + }, + onStderr(chunk: string): void { + stderrBuffer = drainLines(stderrBuffer, chunk); + }, + flush(): void { + if (stdoutBuffer.trim()) { + processLine(stdoutBuffer); + } + if (stderrBuffer.trim()) { + processLine(stderrBuffer); + } + flushQueuedFailureDiagnostics(); + flushPendingError(); + stdoutBuffer = ''; + stderrBuffer = ''; + }, + get xcresultPath(): string | null { + return detectedXcresultPath; + }, + }; +} diff --git a/src/utils/xcodebuild-line-parsers.ts b/src/utils/xcodebuild-line-parsers.ts new file mode 100644 index 00000000..3ff51c4b --- /dev/null +++ b/src/utils/xcodebuild-line-parsers.ts @@ -0,0 +1,160 @@ +export const packageResolutionPatterns = [ + /^Resolve Package Graph$/u, + /^Resolved source packages:/u, + /^Fetching from /u, + /^Checking out /u, + /^Creating working copy /u, + /^Updating https?:\/\//u, +]; + +export const compilePatterns = [ + /^CompileSwift /u, + /^SwiftCompile /u, + /^CompileC /u, + /^ProcessInfoPlistFile /u, + /^PhaseScriptExecution /u, + /^CodeSign /u, + /^CompileAssetCatalog /u, + /^ProcessProductPackaging /u, +]; + +export const linkPatterns = [/^Ld /u]; + +export interface ParsedTestCase { + status: 'passed' | 'failed' | 'skipped'; + rawName: string; + suiteName?: string; + testName: string; + durationText?: string; +} + +export interface ParsedTotals { + executed: number; + failed: number; + durationText?: string; +} + +export interface ParsedFailureDiagnostic { + rawTestName?: string; + suiteName?: string; + testName?: string; + location?: string; + message: string; +} + +export interface ParsedBuildError { + location?: string; + message: string; + renderedLine: string; +} + +function normalizeSuiteName(rawSuiteName: string): string { + const parts = rawSuiteName.split('.').filter(Boolean); + const normalized = parts.length >= 2 ? (parts.at(-1) ?? rawSuiteName) : rawSuiteName; + return normalized.replaceAll('_', ' '); +} + +export function parseRawTestName(rawName: string): { suiteName?: string; testName: string } { + const objcMatch = rawName.match(/^-\[(.+?)\s+(.+)\]$/u); + if (objcMatch) { + return { suiteName: normalizeSuiteName(objcMatch[1]), testName: objcMatch[2] }; + } + + const slashParts = rawName.split('/').filter(Boolean); + if (slashParts.length >= 3) { + return { suiteName: `${slashParts[0]}/${slashParts[1]}`, testName: slashParts[2] }; + } + + if (slashParts.length === 2) { + return { + suiteName: normalizeSuiteName(slashParts[0]), + testName: slashParts[1], + }; + } + + const dotIndex = rawName.lastIndexOf('.'); + if (dotIndex > 0) { + return { suiteName: rawName.slice(0, dotIndex), testName: rawName.slice(dotIndex + 1) }; + } + + return { testName: rawName }; +} + +export function parseTestCaseLine(line: string): ParsedTestCase | null { + const match = line.match(/^Test Case '(.+)' (passed|failed|skipped) \(([^)]+)\)/u); + if (!match) { + return null; + } + const [, rawName, status, durationText] = match; + const { suiteName, testName } = parseRawTestName(rawName); + return { + status: status as 'passed' | 'failed' | 'skipped', + rawName, + suiteName, + testName, + durationText, + }; +} + +export function parseTotalsLine(line: string): ParsedTotals | null { + const match = line.match( + /^Executed (\d+) tests?, with (\d+) failures?(?: \(\d+ unexpected\))? in (.+)$/u, + ); + if (!match) { + return null; + } + return { executed: Number(match[1]), failed: Number(match[2]), durationText: match[3] }; +} + +export function parseFailureDiagnostic(line: string): ParsedFailureDiagnostic | null { + const match = line.match(/^(.*?):(\d+): error: -\[(.+?)\s+(.+?)\] : (.+)$/u); + if (!match) { + return null; + } + const [, filePath, lineNumber, suiteName, testName, message] = match; + return { + rawTestName: `-[${suiteName} ${testName}]`, + suiteName: normalizeSuiteName(suiteName), + testName, + location: lineNumber === '0' ? undefined : `${filePath}:${lineNumber}`, + message: message.replace(/^failed\s*-\s*/u, ''), + }; +} + +export function parseDurationMs(durationText?: string): number | undefined { + if (!durationText) { + return undefined; + } + + const normalized = durationText.trim().replace(/\s+seconds?$/u, 's'); + const match = normalized.match(/^([\d.]+)s$/u); + if (!match) { + return undefined; + } + + const seconds = Number(match[1]); + if (!Number.isFinite(seconds)) { + return undefined; + } + + return Math.round(seconds * 1000); +} + +export function parseBuildErrorDiagnostic(line: string): ParsedBuildError | null { + const locationMatch = line.match(/^(.*?):(\d+)(?::\d+)?: (?:fatal error|error): (.+)$/u); + if (locationMatch) { + const [, filePath, lineNumber, message] = locationMatch; + return { + location: `${filePath}:${lineNumber}`, + message, + renderedLine: line, + }; + } + + const rawMatch = line.match(/^(?:[\w-]+:\s+)?(?:fatal error|error): (.+)$/u); + if (!rawMatch) { + return null; + } + const [, message] = rawMatch; + return { message, renderedLine: line }; +} diff --git a/src/utils/xcodebuild-run-state.ts b/src/utils/xcodebuild-run-state.ts new file mode 100644 index 00000000..4b64790d --- /dev/null +++ b/src/utils/xcodebuild-run-state.ts @@ -0,0 +1,239 @@ +import type { + XcodebuildOperation, + XcodebuildStage, + PipelineEvent, + BuildStageEvent, + CompilerWarningEvent, + CompilerErrorEvent, + TestFailureEvent, +} from '../types/pipeline-events.ts'; +import { STAGE_RANK } from '../types/pipeline-events.ts'; + +export interface XcodebuildRunState { + operation: XcodebuildOperation; + currentStage: XcodebuildStage | null; + milestones: BuildStageEvent[]; + warnings: CompilerWarningEvent[]; + errors: CompilerErrorEvent[]; + testFailures: TestFailureEvent[]; + completedTests: number; + failedTests: number; + skippedTests: number; + finalStatus: 'SUCCEEDED' | 'FAILED' | null; + wallClockDurationMs: number | null; + events: PipelineEvent[]; +} + +export interface RunStateOptions { + operation: XcodebuildOperation; + minimumStage?: XcodebuildStage; + onEvent?: (event: PipelineEvent) => void; +} + +function normalizeDiagnosticKey(location: string | undefined, message: string): string { + return `${location ?? ''}|${message}`.trim().toLowerCase(); +} + +function normalizeTestIdentifier(value: string | undefined): string { + return (value ?? '').trim().replace(/\(\)$/u, '').toLowerCase(); +} + +function normalizeTestFailureLocation(location: string | undefined): string | null { + if (!location) { + return null; + } + + const match = location.match(/([^/]+:\d+(?::\d+)?)$/u); + return (match?.[1] ?? location).trim().toLowerCase(); +} + +function normalizeTestFailureKey(event: TestFailureEvent): string { + const normalizedLocation = normalizeTestFailureLocation(event.location); + const normalizedMessage = event.message.trim().toLowerCase(); + + if (normalizedLocation) { + return `${normalizedLocation}|${normalizedMessage}`; + } + + return `${normalizeTestIdentifier(event.suite)}|${normalizeTestIdentifier(event.test)}|${normalizedMessage}`; +} + +export interface FinalizeOptions { + emitSummary?: boolean; + tailEvents?: PipelineEvent[]; +} + +export interface XcodebuildRunStateHandle { + push(event: PipelineEvent): void; + finalize(succeeded: boolean, durationMs?: number, options?: FinalizeOptions): XcodebuildRunState; + snapshot(): Readonly; + highestStageRank(): number; +} + +export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRunStateHandle { + const { operation, onEvent } = options; + + const state: XcodebuildRunState = { + operation, + currentStage: null, + milestones: [], + warnings: [], + errors: [], + testFailures: [], + completedTests: 0, + failedTests: 0, + skippedTests: 0, + finalStatus: null, + wallClockDurationMs: null, + events: [], + }; + + let highestRank = options.minimumStage !== undefined ? STAGE_RANK[options.minimumStage] : -1; + const seenDiagnostics = new Set(); + + function accept(event: PipelineEvent): void { + state.events.push(event); + onEvent?.(event); + } + + function acceptDedupedDiagnostic( + event: PipelineEvent & T, + collection: T[], + ): void { + const key = normalizeDiagnosticKey(event.location, event.message); + if (seenDiagnostics.has(key)) { + return; + } + seenDiagnostics.add(key); + collection.push(event); + accept(event); + } + + return { + push(event: PipelineEvent): void { + switch (event.type) { + case 'build-stage': { + const rank = STAGE_RANK[event.stage]; + if (rank <= highestRank) { + return; + } + highestRank = rank; + state.currentStage = event.stage; + state.milestones.push(event); + accept(event); + break; + } + + case 'compiler-warning': { + acceptDedupedDiagnostic(event, state.warnings); + break; + } + + case 'compiler-error': { + acceptDedupedDiagnostic(event, state.errors); + break; + } + + case 'test-failure': { + const key = normalizeTestFailureKey(event); + if (seenDiagnostics.has(key)) { + return; + } + seenDiagnostics.add(key); + state.testFailures.push(event); + accept(event); + break; + } + + case 'test-progress': { + state.completedTests = event.completed; + state.failedTests = event.failed; + state.skippedTests = event.skipped; + + if (highestRank < STAGE_RANK.RUN_TESTS) { + const runTestsEvent: BuildStageEvent = { + type: 'build-stage', + timestamp: event.timestamp, + operation: 'TEST', + stage: 'RUN_TESTS', + message: 'Running tests', + }; + highestRank = STAGE_RANK.RUN_TESTS; + state.currentStage = 'RUN_TESTS'; + state.milestones.push(runTestsEvent); + accept(runTestsEvent); + } + + accept(event); + break; + } + + case 'header': + case 'status-line': + case 'section': + case 'detail-tree': + case 'table': + case 'file-ref': + case 'test-discovery': + case 'summary': + case 'next-steps': { + accept(event); + break; + } + } + }, + + finalize( + succeeded: boolean, + durationMs?: number, + options?: FinalizeOptions, + ): XcodebuildRunState { + state.finalStatus = succeeded ? 'SUCCEEDED' : 'FAILED'; + state.wallClockDurationMs = durationMs ?? null; + + if (options?.emitSummary !== false) { + const reconciledFailedTests = Math.max(state.failedTests, state.testFailures.length); + const reconciledPassedTests = Math.max( + 0, + state.completedTests - state.failedTests - state.skippedTests, + ); + const reconciledTotalTests = + operation === 'TEST' + ? reconciledPassedTests + reconciledFailedTests + state.skippedTests + : undefined; + + const summaryEvent: PipelineEvent = { + type: 'summary', + timestamp: new Date().toISOString(), + operation, + status: state.finalStatus, + ...(operation === 'TEST' + ? { + totalTests: reconciledTotalTests, + passedTests: reconciledPassedTests, + failedTests: reconciledFailedTests, + skippedTests: state.skippedTests, + } + : {}), + durationMs, + }; + + accept(summaryEvent); + } + + for (const tailEvent of options?.tailEvents ?? []) { + accept(tailEvent); + } + + return { ...state }; + }, + + snapshot(): Readonly { + return { ...state }; + }, + + highestStageRank(): number { + return highestRank; + }, + }; +} From 255ecb48b49b35ed1ee477cc66b2c3a9b09b0d48 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 08:43:01 +0100 Subject: [PATCH 03/15] fix: preserve suiteName in Swift Testing issue parser The lastIssueDiagnostic type was missing suiteName, causing it to be silently dropped when parsing Swift Testing issue lines. The emitted test-failure event now correctly includes the suite field. --- src/utils/swift-testing-event-parser.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/swift-testing-event-parser.ts b/src/utils/swift-testing-event-parser.ts index 9159d1b1..af4617b5 100644 --- a/src/utils/swift-testing-event-parser.ts +++ b/src/utils/swift-testing-event-parser.ts @@ -37,6 +37,7 @@ export function createSwiftTestingEventParser( let skippedCount = 0; let lastIssueDiagnostic: { + suiteName?: string; testName?: string; message: string; location?: string; @@ -50,6 +51,7 @@ export function createSwiftTestingEventParser( type: 'test-failure', timestamp: now(), operation: 'TEST', + suite: lastIssueDiagnostic.suiteName, test: lastIssueDiagnostic.testName, message: lastIssueDiagnostic.message, location: lastIssueDiagnostic.location, @@ -88,6 +90,7 @@ export function createSwiftTestingEventParser( const issue = parseSwiftTestingIssueLine(line); if (issue) { lastIssueDiagnostic = { + suiteName: issue.suiteName, testName: issue.testName, message: issue.message, location: issue.location, From 904c110bef12d2c5feab8272c91521eb6b1e4cd7 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 10:33:10 +0100 Subject: [PATCH 04/15] fix: handle arbitrary nesting depth in parseRawTestName The >= 3 slash-part branch hardcoded indices 0, 1, 2 which dropped parts beyond the third. Swift Testing supports nested suites so names like Module/OuterSuite/InnerSuite/testMethod are possible. Now uses slice/at to preserve the full suite path and final test name. --- src/utils/xcodebuild-line-parsers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/xcodebuild-line-parsers.ts b/src/utils/xcodebuild-line-parsers.ts index 3ff51c4b..fc8aefa8 100644 --- a/src/utils/xcodebuild-line-parsers.ts +++ b/src/utils/xcodebuild-line-parsers.ts @@ -62,7 +62,7 @@ export function parseRawTestName(rawName: string): { suiteName?: string; testNam const slashParts = rawName.split('/').filter(Boolean); if (slashParts.length >= 3) { - return { suiteName: `${slashParts[0]}/${slashParts[1]}`, testName: slashParts[2] }; + return { suiteName: slashParts.slice(0, -1).join('/'), testName: slashParts.at(-1)! }; } if (slashParts.length === 2) { From 8415cd74a09f345e4dc3d334b9ec9752f681e97a Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 11:39:24 +0100 Subject: [PATCH 05/15] fix: delete consumed pendingFailureDurations entries to prevent stale leaks The pendingFailureDurations map was never cleaned up after a duration was consumed, allowing stale entries to attach incorrect durations to later failures with the same suite/test key in retry scenarios. Also adds a clarifying comment on the Swift Testing issue-count-as- failed-count approximation. --- src/utils/swift-testing-line-parsers.ts | 5 +++++ src/utils/xcodebuild-event-parser.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/utils/swift-testing-line-parsers.ts b/src/utils/swift-testing-line-parsers.ts index 08288aaf..9ead5e23 100644 --- a/src/utils/swift-testing-line-parsers.ts +++ b/src/utils/swift-testing-line-parsers.ts @@ -110,6 +110,11 @@ export function parseSwiftTestingRunSummary(line: string): ParsedTotals | null { const total = Number(match[1]); const durationText = `${match[2]}s`; + // Swift Testing reports "issues" not "failed tests" -- a single test can produce + // multiple issues (e.g. multiple #expect failures). This is the best available + // approximation; the framework doesn't report a distinct failed-test count in its + // summary line. Downstream reconciliation via Math.max(failedTests, testFailures.length) + // partially mitigates overcounting. const issueMatch = line.match(/with (\d+) issues?/u); const failed = issueMatch ? Number(issueMatch[1]) : 0; diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts index 420ef289..d98fd092 100644 --- a/src/utils/xcodebuild-event-parser.ts +++ b/src/utils/xcodebuild-event-parser.ts @@ -176,6 +176,7 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb const durationMs = pendingFailureDurations.get(key); if (durationMs !== undefined) { + pendingFailureDurations.delete(key); emitFailureEvent({ ...failure, durationMs }); return; } @@ -211,6 +212,7 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb emitFailureEvent({ ...failure, durationMs }); } pendingFailureDiagnostics.delete(key); + pendingFailureDurations.delete(key); } function emitTestProgress(): void { From ccb2ed9463746f52c978990eac3d039938a1fdb4 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 11:55:54 +0100 Subject: [PATCH 06/15] fix: correct parser patterns validated against real xcodebuild output Fixes discovered by auditing synthetic test data against real swift test and xcodebuild output: - Swift Testing verbose mode: (aka 'func()') suffix now optionally matched in result and issue parsers - Skipped test format: real output uses arrow symbol and bare function name, not diamond with quoted name. Both formats now handled. - Parameterized tests: 'with N test cases' suffix in results and 'with N argument value' in issues now matched - Build errors without line numbers: .xcodeproj path-based errors like 'Missing package product' now parsed - Stage detection: lowercase 'Test suite' from Xcode 26 now matched Tests updated to use patterns captured from real swift test and xcodebuild runs. --- .../swift-testing-line-parsers.test.ts | 120 +++++++++++++++++- src/utils/swift-testing-line-parsers.ts | 50 ++++++-- src/utils/xcodebuild-event-parser.ts | 4 +- src/utils/xcodebuild-line-parsers.ts | 13 ++ 4 files changed, 175 insertions(+), 12 deletions(-) diff --git a/src/utils/__tests__/swift-testing-line-parsers.test.ts b/src/utils/__tests__/swift-testing-line-parsers.test.ts index 07908fe2..6a6439fa 100644 --- a/src/utils/__tests__/swift-testing-line-parsers.test.ts +++ b/src/utils/__tests__/swift-testing-line-parsers.test.ts @@ -21,6 +21,30 @@ describe('Swift Testing line parsers', () => { }); }); + it('should parse a passed test with verbose aka suffix', () => { + const result = parseSwiftTestingResultLine( + "✔ Test \"String operations\" (aka 'stringTest()') passed after 0.001 seconds.", + ); + expect(result).toEqual({ + status: 'passed', + rawName: 'String operations', + testName: 'String operations', + durationText: '0.001s', + }); + }); + + it('should parse a passed parameterized test', () => { + const result = parseSwiftTestingResultLine( + '✔ Test "Parameterized test" with 3 test cases passed after 0.001 seconds.', + ); + expect(result).toEqual({ + status: 'passed', + rawName: 'Parameterized test', + testName: 'Parameterized test', + durationText: '0.001s', + }); + }); + it('should parse a failed test', () => { const result = parseSwiftTestingResultLine( '✘ Test "Expected failure" failed after 0.001 seconds with 1 issue.', @@ -33,7 +57,40 @@ describe('Swift Testing line parsers', () => { }); }); - it('should parse a skipped test', () => { + it('should parse a failed test with verbose aka suffix', () => { + const result = parseSwiftTestingResultLine( + "✘ Test \"Expected failure\" (aka 'deliberateFailure()') failed after 0.001 seconds with 1 issue.", + ); + expect(result).toEqual({ + status: 'failed', + rawName: 'Expected failure', + testName: 'Expected failure', + durationText: '0.001s', + }); + }); + + it('should parse a failed parameterized test', () => { + const result = parseSwiftTestingResultLine( + '✘ Test "Parameterized failure" with 3 test cases failed after 0.001 seconds with 1 issue.', + ); + expect(result).toEqual({ + status: 'failed', + rawName: 'Parameterized failure', + testName: 'Parameterized failure', + durationText: '0.001s', + }); + }); + + it('should parse a skipped test (arrow format)', () => { + const result = parseSwiftTestingResultLine('➜ Test disabledTest() skipped: "Not ready yet"'); + expect(result).toEqual({ + status: 'skipped', + rawName: 'disabledTest', + testName: 'disabledTest', + }); + }); + + it('should parse a skipped test (legacy diamond format)', () => { const result = parseSwiftTestingResultLine('◇ Test "Disabled test" skipped.'); expect(result).toEqual({ status: 'skipped', @@ -42,6 +99,15 @@ describe('Swift Testing line parsers', () => { }); }); + it('should parse a skipped test without reason', () => { + const result = parseSwiftTestingResultLine('➜ Test disabledTest skipped'); + expect(result).toEqual({ + status: 'skipped', + rawName: 'disabledTest', + testName: 'disabledTest', + }); + }); + it('should return null for non-matching lines', () => { expect(parseSwiftTestingResultLine('◇ Test "Foo" started.')).toBeNull(); expect(parseSwiftTestingResultLine('random text')).toBeNull(); @@ -61,6 +127,30 @@ describe('Swift Testing line parsers', () => { }); }); + it('should parse an issue with verbose aka suffix', () => { + const result = parseSwiftTestingIssueLine( + "✘ Test \"Expected failure\" (aka 'deliberateFailure()') recorded an issue at AuditTests.swift:5:5: Expectation failed: true == false", + ); + expect(result).toEqual({ + rawTestName: 'Expected failure', + testName: 'Expected failure', + location: 'AuditTests.swift:5', + message: 'Expectation failed: true == false', + }); + }); + + it('should parse a parameterized issue with argument values', () => { + const result = parseSwiftTestingIssueLine( + '✘ Test "Parameterized failure" recorded an issue with 1 argument value → 0 at ParameterizedTests.swift:10:5: Expectation failed: (value → 0) > 0', + ); + expect(result).toEqual({ + rawTestName: 'Parameterized failure', + testName: 'Parameterized failure', + location: 'ParameterizedTests.swift:10', + message: 'Expectation failed: (value → 0) > 0', + }); + }); + it('should parse an issue without location', () => { const result = parseSwiftTestingIssueLine( '✘ Test "Some test" recorded an issue: Something went wrong', @@ -72,6 +162,17 @@ describe('Swift Testing line parsers', () => { }); }); + it('should parse an issue without location with verbose aka suffix', () => { + const result = parseSwiftTestingIssueLine( + "✘ Test \"Some test\" (aka 'someFunc()') recorded an issue: Something went wrong", + ); + expect(result).toEqual({ + rawTestName: 'Some test', + testName: 'Some test', + message: 'Something went wrong', + }); + }); + it('should return null for non-matching lines', () => { expect(parseSwiftTestingIssueLine('✘ Test "Foo" failed after 0.001 seconds')).toBeNull(); }); @@ -100,6 +201,17 @@ describe('Swift Testing line parsers', () => { }); }); + it('should parse a summary with singular suite', () => { + const result = parseSwiftTestingRunSummary( + '✘ Test run with 5 tests in 1 suite failed after 0.001 seconds with 3 issues.', + ); + expect(result).toEqual({ + executed: 5, + failed: 3, + durationText: '0.001s', + }); + }); + it('should return null for non-matching lines', () => { expect(parseSwiftTestingRunSummary('random text')).toBeNull(); }); @@ -112,6 +224,12 @@ describe('Swift Testing line parsers', () => { ); }); + it('should parse a continuation with version info', () => { + expect(parseSwiftTestingContinuationLine('↳ Testing Library Version: 1743')).toBe( + 'Testing Library Version: 1743', + ); + }); + it('should return null for non-continuation lines', () => { expect(parseSwiftTestingContinuationLine('regular line')).toBeNull(); }); diff --git a/src/utils/swift-testing-line-parsers.ts b/src/utils/swift-testing-line-parsers.ts index 9ead5e23..3b4f3c13 100644 --- a/src/utils/swift-testing-line-parsers.ts +++ b/src/utils/swift-testing-line-parsers.ts @@ -5,17 +5,29 @@ import { parseRawTestName, } from './xcodebuild-line-parsers.ts'; +// Optional verbose suffix: (aka 'funcName()') +// Optional parameterized suffix: with N test cases +const OPTIONAL_AKA = `(?:\\s*\\(aka '[^']*'\\))?`; +const OPTIONAL_PARAMETERIZED = `(?:\\s+with \\d+ test cases?)?`; + /** * Parse a Swift Testing result line (passed/failed/skipped). * - * Matches: + * Matches (non-verbose and verbose): * ✔ Test "Name" passed after 0.001 seconds. + * ✔ Test "Name" (aka 'func()') passed after 0.001 seconds. + * ✔ Test "Name" with 3 test cases passed after 0.001 seconds. * ✘ Test "Name" failed after 0.001 seconds with 1 issue. - * ✘ Test "Name" failed after 0.001 seconds with 3 issues. - * ◇ Test "Name" skipped. + * ✘ Test "Name" (aka 'func()') failed after 0.001 seconds with 1 issue. + * ➜ Test funcName() skipped: "reason" + * ➜ Test funcName() skipped */ export function parseSwiftTestingResultLine(line: string): ParsedTestCase | null { - const passedMatch = line.match(/^[✔] Test "(.+)" passed after ([\d.]+) seconds\.?$/u); + const passedRegex = new RegExp( + `^[✔] Test "(.+)"${OPTIONAL_AKA}${OPTIONAL_PARAMETERIZED} passed after ([\\d.]+) seconds\\.?$`, + 'u', + ); + const passedMatch = line.match(passedRegex); if (passedMatch) { const [, name, duration] = passedMatch; const { suiteName, testName } = parseRawTestName(name); @@ -28,7 +40,11 @@ export function parseSwiftTestingResultLine(line: string): ParsedTestCase | null }; } - const failedMatch = line.match(/^[✘] Test "(.+)" failed after ([\d.]+) seconds/u); + const failedRegex = new RegExp( + `^[✘] Test "(.+)"${OPTIONAL_AKA}${OPTIONAL_PARAMETERIZED} failed after ([\\d.]+) seconds`, + 'u', + ); + const failedMatch = line.match(failedRegex); if (failedMatch) { const [, name, duration] = failedMatch; const { suiteName, testName } = parseRawTestName(name); @@ -41,7 +57,11 @@ export function parseSwiftTestingResultLine(line: string): ParsedTestCase | null }; } - const skippedMatch = line.match(/^[◇] Test "(.+)" skipped/u); + // Skipped: ➜ Test funcName() skipped: "reason" + // Also handle legacy format: ◇ Test "Name" skipped + const skippedMatch = + line.match(/^[➜] Test (\S+?)(?:\(\))? skipped/u) ?? + line.match(/^[◇] Test "(.+)" skipped/u); if (skippedMatch) { const rawName = skippedMatch[1]; const { suiteName, testName } = parseRawTestName(rawName); @@ -59,12 +79,19 @@ export function parseSwiftTestingResultLine(line: string): ParsedTestCase | null /** * Parse a Swift Testing issue line. * - * Matches: + * Matches (non-verbose and verbose, including parameterized): * ✘ Test "Name" recorded an issue at File.swift:48:5: Expectation failed: ... + * ✘ Test "Name" (aka 'func()') recorded an issue at File.swift:48:5: msg + * ✘ Test "Name" recorded an issue with 1 argument value → 0 at File.swift:10:5: msg * ✘ Test "Name" recorded an issue: message */ export function parseSwiftTestingIssueLine(line: string): ParsedFailureDiagnostic | null { - const locationMatch = line.match(/^[✘] Test "(.+)" recorded an issue at (.+?):(\d+):\d+: (.+)$/u); + // Match with location -- handle both aka suffix and parameterized argument values before "at" + const locationRegex = new RegExp( + `^[✘] Test "(.+)"${OPTIONAL_AKA} recorded an issue(?:\\s+with \\d+ argument values?[^:]*?)? at (.+?):(\\d+):\\d+: (.+)$`, + 'u', + ); + const locationMatch = line.match(locationRegex); if (locationMatch) { const [, rawTestName, filePath, lineNumber, message] = locationMatch; const { suiteName, testName } = parseRawTestName(rawTestName); @@ -77,7 +104,12 @@ export function parseSwiftTestingIssueLine(line: string): ParsedFailureDiagnosti }; } - const simpleMatch = line.match(/^[✘] Test "(.+)" recorded an issue: (.+)$/u); + // Match without location + const simpleRegex = new RegExp( + `^[✘] Test "(.+)"${OPTIONAL_AKA} recorded an issue: (.+)$`, + 'u', + ); + const simpleMatch = line.match(simpleRegex); if (simpleMatch) { const [, rawTestName, message] = simpleMatch; const { suiteName, testName } = parseRawTestName(rawTestName); diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts index d98fd092..f23c234b 100644 --- a/src/utils/xcodebuild-event-parser.ts +++ b/src/utils/xcodebuild-event-parser.ts @@ -33,7 +33,7 @@ function resolveStageFromLine(line: string): XcodebuildStage | null { } if ( /^Testing started$/u.test(line) || - /^Test Suite .+ started/u.test(line) || + /^Test [Ss]uite .+ started/u.test(line) || /^[◇] Test run started/u.test(line) ) { return 'RUN_TESTS'; @@ -373,7 +373,7 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb return; } - if (/^Test Suite /u.test(line)) { + if (/^Test [Ss]uite /u.test(line)) { return; } diff --git a/src/utils/xcodebuild-line-parsers.ts b/src/utils/xcodebuild-line-parsers.ts index fc8aefa8..69f74803 100644 --- a/src/utils/xcodebuild-line-parsers.ts +++ b/src/utils/xcodebuild-line-parsers.ts @@ -141,6 +141,7 @@ export function parseDurationMs(durationText?: string): number | undefined { } export function parseBuildErrorDiagnostic(line: string): ParsedBuildError | null { + // File path with line number: /path/to/File.swift:42:10: error: message const locationMatch = line.match(/^(.*?):(\d+)(?::\d+)?: (?:fatal error|error): (.+)$/u); if (locationMatch) { const [, filePath, lineNumber, message] = locationMatch; @@ -151,6 +152,18 @@ export function parseBuildErrorDiagnostic(line: string): ParsedBuildError | null }; } + // Path-based error without line number: /path/to/Project.xcodeproj: error: message + const pathErrorMatch = line.match(/^(\/[^:]+): (?:fatal error|error): (.+)$/u); + if (pathErrorMatch) { + const [, filePath, message] = pathErrorMatch; + return { + location: filePath, + message, + renderedLine: line, + }; + } + + // Prefixed error: xcodebuild: error: message / error: message const rawMatch = line.match(/^(?:[\w-]+:\s+)?(?:fatal error|error): (.+)$/u); if (!rawMatch) { return null; From 1f3cb73b9ebd792b2db5632b609cbbbcd2f1bf1b Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 12:22:39 +0100 Subject: [PATCH 07/15] fix: snapshot array mutation, overly broad noise regex, and Swift Testing failure duration - Deep-copy arrays in snapshot() and finalize() so returned state is truly immutable and won't be mutated by subsequent push() calls - Replace overly broad noise regex that matched any 'identifier: content' line (swallowing compiler note: diagnostics) with a targeted pattern that only matches SPM resolved dependency lines (PackageName: https://...) - Attach failure duration from Swift Testing result lines by checking the result line before flushing the pending issue diagnostic. Previously all Swift Testing failure durations were silently dropped. --- src/utils/swift-testing-event-parser.ts | 28 ++++++++++++++++++++----- src/utils/xcodebuild-event-parser.ts | 2 +- src/utils/xcodebuild-run-state.ts | 18 ++++++++++++++-- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/utils/swift-testing-event-parser.ts b/src/utils/swift-testing-event-parser.ts index af4617b5..a797d66d 100644 --- a/src/utils/swift-testing-event-parser.ts +++ b/src/utils/swift-testing-event-parser.ts @@ -9,6 +9,7 @@ import { parseTestCaseLine, parseTotalsLine, parseFailureDiagnostic, + parseDurationMs, } from './xcodebuild-line-parsers.ts'; export interface SwiftTestingEventParser { @@ -84,6 +85,27 @@ export function createSwiftTestingEventParser( return; } + // Check result line BEFORE flushing so we can attach duration to pending issue + const stResult = parseSwiftTestingResultLine(line); + if (stResult && stResult.status === 'failed' && lastIssueDiagnostic) { + const durationMs = parseDurationMs(stResult.durationText); + onEvent({ + type: 'test-failure', + timestamp: now(), + operation: 'TEST', + suite: lastIssueDiagnostic.suiteName, + test: lastIssueDiagnostic.testName, + message: lastIssueDiagnostic.message, + location: lastIssueDiagnostic.location, + durationMs, + }); + lastIssueDiagnostic = null; + completedCount += 1; + failedCount += 1; + emitTestProgress(); + return; + } + flushPendingIssue(); // Swift Testing issue line: ✘ Test "Name" recorded an issue at file:line:col: message @@ -98,13 +120,9 @@ export function createSwiftTestingEventParser( return; } - // Swift Testing result line: ✔/✘/◇ Test "Name" passed/failed/skipped - const stResult = parseSwiftTestingResultLine(line); + // Swift Testing result line: ✔/✘/◇ Test "Name" passed/failed/skipped (non-failure or no pending issue) if (stResult) { completedCount += 1; - if (stResult.status === 'failed') { - failedCount += 1; - } if (stResult.status === 'skipped') { skippedCount += 1; } diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts index f23c234b..cf3ae0b9 100644 --- a/src/utils/xcodebuild-event-parser.ts +++ b/src/utils/xcodebuild-event-parser.ts @@ -76,7 +76,7 @@ const IGNORED_NOISE_PATTERNS = [ /^(?:COMPILER_INDEX_STORE_ENABLE|ONLY_ACTIVE_ARCH)\s*=\s*.+$/u, /^Resolve Package Graph$/u, /^Resolved source packages:$/u, - /^\s*[A-Za-z0-9_.-]+:\s+.+$/u, + /^\s*[A-Za-z0-9_.-]+:\s+https?:\/\/.+$/u, /^--- xcodebuild: WARNING: Using the first of multiple matching destinations:$/u, /^\{\s*platform:.+\}$/u, /^(?:ComputePackagePrebuildTargetDependencyGraph|Prepare packages|CreateBuildRequest|SendProjectDescription|CreateBuildOperation|ComputeTargetDependencyGraph|GatherProvisioningInputs|CreateBuildDescription)$/u, diff --git a/src/utils/xcodebuild-run-state.ts b/src/utils/xcodebuild-run-state.ts index 4b64790d..6f17224d 100644 --- a/src/utils/xcodebuild-run-state.ts +++ b/src/utils/xcodebuild-run-state.ts @@ -225,11 +225,25 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu accept(tailEvent); } - return { ...state }; + return { + ...state, + events: [...state.events], + milestones: [...state.milestones], + warnings: [...state.warnings], + errors: [...state.errors], + testFailures: [...state.testFailures], + }; }, snapshot(): Readonly { - return { ...state }; + return { + ...state, + events: [...state.events], + milestones: [...state.milestones], + warnings: [...state.warnings], + errors: [...state.errors], + testFailures: [...state.testFailures], + }; }, highestStageRank(): number { From ac3f9c1e438054ee2bd9ebe9dbd7cbf4016d6d46 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 12:31:15 +0100 Subject: [PATCH 08/15] fix: apply prettier formatting to Swift Testing parser files --- src/utils/__tests__/swift-testing-line-parsers.test.ts | 8 ++++---- src/utils/swift-testing-line-parsers.ts | 8 ++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/utils/__tests__/swift-testing-line-parsers.test.ts b/src/utils/__tests__/swift-testing-line-parsers.test.ts index 6a6439fa..696da4f9 100644 --- a/src/utils/__tests__/swift-testing-line-parsers.test.ts +++ b/src/utils/__tests__/swift-testing-line-parsers.test.ts @@ -23,7 +23,7 @@ describe('Swift Testing line parsers', () => { it('should parse a passed test with verbose aka suffix', () => { const result = parseSwiftTestingResultLine( - "✔ Test \"String operations\" (aka 'stringTest()') passed after 0.001 seconds.", + '✔ Test "String operations" (aka \'stringTest()\') passed after 0.001 seconds.', ); expect(result).toEqual({ status: 'passed', @@ -59,7 +59,7 @@ describe('Swift Testing line parsers', () => { it('should parse a failed test with verbose aka suffix', () => { const result = parseSwiftTestingResultLine( - "✘ Test \"Expected failure\" (aka 'deliberateFailure()') failed after 0.001 seconds with 1 issue.", + '✘ Test "Expected failure" (aka \'deliberateFailure()\') failed after 0.001 seconds with 1 issue.', ); expect(result).toEqual({ status: 'failed', @@ -129,7 +129,7 @@ describe('Swift Testing line parsers', () => { it('should parse an issue with verbose aka suffix', () => { const result = parseSwiftTestingIssueLine( - "✘ Test \"Expected failure\" (aka 'deliberateFailure()') recorded an issue at AuditTests.swift:5:5: Expectation failed: true == false", + '✘ Test "Expected failure" (aka \'deliberateFailure()\') recorded an issue at AuditTests.swift:5:5: Expectation failed: true == false', ); expect(result).toEqual({ rawTestName: 'Expected failure', @@ -164,7 +164,7 @@ describe('Swift Testing line parsers', () => { it('should parse an issue without location with verbose aka suffix', () => { const result = parseSwiftTestingIssueLine( - "✘ Test \"Some test\" (aka 'someFunc()') recorded an issue: Something went wrong", + '✘ Test "Some test" (aka \'someFunc()\') recorded an issue: Something went wrong', ); expect(result).toEqual({ rawTestName: 'Some test', diff --git a/src/utils/swift-testing-line-parsers.ts b/src/utils/swift-testing-line-parsers.ts index 3b4f3c13..3739b76b 100644 --- a/src/utils/swift-testing-line-parsers.ts +++ b/src/utils/swift-testing-line-parsers.ts @@ -60,8 +60,7 @@ export function parseSwiftTestingResultLine(line: string): ParsedTestCase | null // Skipped: ➜ Test funcName() skipped: "reason" // Also handle legacy format: ◇ Test "Name" skipped const skippedMatch = - line.match(/^[➜] Test (\S+?)(?:\(\))? skipped/u) ?? - line.match(/^[◇] Test "(.+)" skipped/u); + line.match(/^[➜] Test (\S+?)(?:\(\))? skipped/u) ?? line.match(/^[◇] Test "(.+)" skipped/u); if (skippedMatch) { const rawName = skippedMatch[1]; const { suiteName, testName } = parseRawTestName(rawName); @@ -105,10 +104,7 @@ export function parseSwiftTestingIssueLine(line: string): ParsedFailureDiagnosti } // Match without location - const simpleRegex = new RegExp( - `^[✘] Test "(.+)"${OPTIONAL_AKA} recorded an issue: (.+)$`, - 'u', - ); + const simpleRegex = new RegExp(`^[✘] Test "(.+)"${OPTIONAL_AKA} recorded an issue: (.+)$`, 'u'); const simpleMatch = line.match(simpleRegex); if (simpleMatch) { const [, rawTestName, message] = simpleMatch; From 5b1e7851e0dd9fe0fa1d60fba8c87983e6879c17 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 12:48:25 +0100 Subject: [PATCH 09/15] fix: increment failedCount for failed results without pending issue --- src/utils/swift-testing-event-parser.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/swift-testing-event-parser.ts b/src/utils/swift-testing-event-parser.ts index a797d66d..eaacfad3 100644 --- a/src/utils/swift-testing-event-parser.ts +++ b/src/utils/swift-testing-event-parser.ts @@ -123,6 +123,9 @@ export function createSwiftTestingEventParser( // Swift Testing result line: ✔/✘/◇ Test "Name" passed/failed/skipped (non-failure or no pending issue) if (stResult) { completedCount += 1; + if (stResult.status === 'failed') { + failedCount += 1; + } if (stResult.status === 'skipped') { skippedCount += 1; } From f7a685c477aba4e820f38d0f274b540a5141eeb0 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 12:58:59 +0100 Subject: [PATCH 10/15] test: add failing tests for dedup key collision and parameterized case count Two bugs reproduced: - Test failure dedup key uses only location|message, collapsing distinct failures from different tests sharing the same assertion line - Parameterized test results drop the case count, undercounting progress --- .../swift-testing-line-parsers.test.ts | 16 +++++++++++- .../__tests__/xcodebuild-run-state.test.ts | 25 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/swift-testing-line-parsers.test.ts b/src/utils/__tests__/swift-testing-line-parsers.test.ts index 696da4f9..6979748b 100644 --- a/src/utils/__tests__/swift-testing-line-parsers.test.ts +++ b/src/utils/__tests__/swift-testing-line-parsers.test.ts @@ -33,7 +33,7 @@ describe('Swift Testing line parsers', () => { }); }); - it('should parse a passed parameterized test', () => { + it('should parse a passed parameterized test with case count', () => { const result = parseSwiftTestingResultLine( '✔ Test "Parameterized test" with 3 test cases passed after 0.001 seconds.', ); @@ -42,6 +42,20 @@ describe('Swift Testing line parsers', () => { rawName: 'Parameterized test', testName: 'Parameterized test', durationText: '0.001s', + caseCount: 3, + }); + }); + + it('should parse a failed parameterized test with case count', () => { + const result = parseSwiftTestingResultLine( + '✘ Test "Parameterized failure" with 3 test cases failed after 0.001 seconds with 1 issue.', + ); + expect(result).toEqual({ + status: 'failed', + rawName: 'Parameterized failure', + testName: 'Parameterized failure', + durationText: '0.001s', + caseCount: 3, }); }); diff --git a/src/utils/__tests__/xcodebuild-run-state.test.ts b/src/utils/__tests__/xcodebuild-run-state.test.ts index 6d0e0f7d..fd8dd3f3 100644 --- a/src/utils/__tests__/xcodebuild-run-state.test.ts +++ b/src/utils/__tests__/xcodebuild-run-state.test.ts @@ -356,6 +356,31 @@ describe('xcodebuild-run-state', () => { expect(state.highestStageRank()).toBe(STAGE_RANK.COMPILING); }); + it('does not deduplicate distinct test failures sharing the same assertion location', () => { + const state = createXcodebuildRunState({ operation: 'TEST' }); + + state.push({ + type: 'test-failure', + timestamp: ts(), + operation: 'TEST', + suite: 'SuiteA', + test: 'testOne', + message: 'XCTAssertTrue failed', + location: '/tmp/SharedAssert.swift:10', + }); + state.push({ + type: 'test-failure', + timestamp: ts(), + operation: 'TEST', + suite: 'SuiteB', + test: 'testTwo', + message: 'XCTAssertTrue failed', + location: '/tmp/SharedAssert.swift:10', + }); + + expect(state.snapshot().testFailures).toHaveLength(2); + }); + it('passes through header and next-steps events', () => { const forwarded: PipelineEvent[] = []; const state = createXcodebuildRunState({ From e3e04ac4580404dd41cdb252a04c773541ceed94 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 13:01:38 +0100 Subject: [PATCH 11/15] fix: include test name in dedup key and capture parameterized case count - Test failure dedup key now includes the test name when a location is present, preventing distinct failures from different tests sharing the same assertion line from being collapsed. Suite name is excluded from the location-present key because it disagrees between xcresult and live parsing sources. - ParsedTestCase gains a caseCount field populated from the 'with N test cases' suffix in Swift Testing parameterized results. Event parsers increment progress by caseCount instead of 1. --- .../__tests__/swift-testing-line-parsers.test.ts | 11 ----------- src/utils/swift-testing-event-parser.ts | 12 +++++++----- src/utils/swift-testing-line-parsers.ts | 10 +++++++--- src/utils/xcodebuild-line-parsers.ts | 1 + src/utils/xcodebuild-run-state.ts | 8 ++++++-- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/utils/__tests__/swift-testing-line-parsers.test.ts b/src/utils/__tests__/swift-testing-line-parsers.test.ts index 6979748b..ec3f9e8d 100644 --- a/src/utils/__tests__/swift-testing-line-parsers.test.ts +++ b/src/utils/__tests__/swift-testing-line-parsers.test.ts @@ -83,17 +83,6 @@ describe('Swift Testing line parsers', () => { }); }); - it('should parse a failed parameterized test', () => { - const result = parseSwiftTestingResultLine( - '✘ Test "Parameterized failure" with 3 test cases failed after 0.001 seconds with 1 issue.', - ); - expect(result).toEqual({ - status: 'failed', - rawName: 'Parameterized failure', - testName: 'Parameterized failure', - durationText: '0.001s', - }); - }); it('should parse a skipped test (arrow format)', () => { const result = parseSwiftTestingResultLine('➜ Test disabledTest() skipped: "Not ready yet"'); diff --git a/src/utils/swift-testing-event-parser.ts b/src/utils/swift-testing-event-parser.ts index eaacfad3..6f4135ac 100644 --- a/src/utils/swift-testing-event-parser.ts +++ b/src/utils/swift-testing-event-parser.ts @@ -100,8 +100,9 @@ export function createSwiftTestingEventParser( durationMs, }); lastIssueDiagnostic = null; - completedCount += 1; - failedCount += 1; + const increment = stResult.caseCount ?? 1; + completedCount += increment; + failedCount += increment; emitTestProgress(); return; } @@ -122,12 +123,13 @@ export function createSwiftTestingEventParser( // Swift Testing result line: ✔/✘/◇ Test "Name" passed/failed/skipped (non-failure or no pending issue) if (stResult) { - completedCount += 1; + const increment = stResult.caseCount ?? 1; + completedCount += increment; if (stResult.status === 'failed') { - failedCount += 1; + failedCount += increment; } if (stResult.status === 'skipped') { - skippedCount += 1; + skippedCount += increment; } emitTestProgress(); return; diff --git a/src/utils/swift-testing-line-parsers.ts b/src/utils/swift-testing-line-parsers.ts index 3739b76b..93836a0d 100644 --- a/src/utils/swift-testing-line-parsers.ts +++ b/src/utils/swift-testing-line-parsers.ts @@ -8,7 +8,7 @@ import { // Optional verbose suffix: (aka 'funcName()') // Optional parameterized suffix: with N test cases const OPTIONAL_AKA = `(?:\\s*\\(aka '[^']*'\\))?`; -const OPTIONAL_PARAMETERIZED = `(?:\\s+with \\d+ test cases?)?`; +const OPTIONAL_PARAMETERIZED = `(?:\\s+with (\\d+) test cases?)?`; /** * Parse a Swift Testing result line (passed/failed/skipped). @@ -29,14 +29,16 @@ export function parseSwiftTestingResultLine(line: string): ParsedTestCase | null ); const passedMatch = line.match(passedRegex); if (passedMatch) { - const [, name, duration] = passedMatch; + const [, name, caseCountStr, duration] = passedMatch; const { suiteName, testName } = parseRawTestName(name); + const caseCount = caseCountStr ? Number(caseCountStr) : undefined; return { status: 'passed', rawName: name, suiteName, testName, durationText: `${duration}s`, + ...(caseCount !== undefined && { caseCount }), }; } @@ -46,14 +48,16 @@ export function parseSwiftTestingResultLine(line: string): ParsedTestCase | null ); const failedMatch = line.match(failedRegex); if (failedMatch) { - const [, name, duration] = failedMatch; + const [, name, caseCountStr, duration] = failedMatch; const { suiteName, testName } = parseRawTestName(name); + const caseCount = caseCountStr ? Number(caseCountStr) : undefined; return { status: 'failed', rawName: name, suiteName, testName, durationText: `${duration}s`, + ...(caseCount !== undefined && { caseCount }), }; } diff --git a/src/utils/xcodebuild-line-parsers.ts b/src/utils/xcodebuild-line-parsers.ts index 69f74803..4f5ba0c6 100644 --- a/src/utils/xcodebuild-line-parsers.ts +++ b/src/utils/xcodebuild-line-parsers.ts @@ -26,6 +26,7 @@ export interface ParsedTestCase { suiteName?: string; testName: string; durationText?: string; + caseCount?: number; } export interface ParsedTotals { diff --git a/src/utils/xcodebuild-run-state.ts b/src/utils/xcodebuild-run-state.ts index 6f17224d..1ea7fbb9 100644 --- a/src/utils/xcodebuild-run-state.ts +++ b/src/utils/xcodebuild-run-state.ts @@ -50,12 +50,16 @@ function normalizeTestFailureLocation(location: string | undefined): string | nu function normalizeTestFailureKey(event: TestFailureEvent): string { const normalizedLocation = normalizeTestFailureLocation(event.location); const normalizedMessage = event.message.trim().toLowerCase(); + const suite = normalizeTestIdentifier(event.suite); + const test = normalizeTestIdentifier(event.test); if (normalizedLocation) { - return `${normalizedLocation}|${normalizedMessage}`; + // Include test name but NOT suite -- suite naming disagrees between xcresult + // and live parsing (e.g. 'Module.Suite' vs absent). Test name is consistent. + return `${test}|${normalizedLocation}|${normalizedMessage}`; } - return `${normalizeTestIdentifier(event.suite)}|${normalizeTestIdentifier(event.test)}|${normalizedMessage}`; + return `${suite}|${test}|${normalizedMessage}`; } export interface FinalizeOptions { From fdad5b6469b0db4c6acf19335d4bb5099bea1f28 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 15:43:18 +0100 Subject: [PATCH 12/15] fix: handle colons in parameterized argument values and fix formatting The issue regex used [^:]*? to match argument values before 'at', which failed when argument values contained colons (e.g. key:value). Changed to .*? and rely on the file:line:col anchor to correctly backtrack. Also fixes prettier formatting from previous commit. --- .../__tests__/swift-testing-line-parsers.test.ts | 13 ++++++++++++- src/utils/swift-testing-line-parsers.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/utils/__tests__/swift-testing-line-parsers.test.ts b/src/utils/__tests__/swift-testing-line-parsers.test.ts index ec3f9e8d..d771e39b 100644 --- a/src/utils/__tests__/swift-testing-line-parsers.test.ts +++ b/src/utils/__tests__/swift-testing-line-parsers.test.ts @@ -83,7 +83,6 @@ describe('Swift Testing line parsers', () => { }); }); - it('should parse a skipped test (arrow format)', () => { const result = parseSwiftTestingResultLine('➜ Test disabledTest() skipped: "Not ready yet"'); expect(result).toEqual({ @@ -154,6 +153,18 @@ describe('Swift Testing line parsers', () => { }); }); + it('should parse a parameterized issue with colon in argument value', () => { + const result = parseSwiftTestingIssueLine( + '✘ Test "Dict test" recorded an issue with 1 argument value → key:value at DictTests.swift:5:3: failed', + ); + expect(result).toEqual({ + rawTestName: 'Dict test', + testName: 'Dict test', + location: 'DictTests.swift:5', + message: 'failed', + }); + }); + it('should parse an issue without location', () => { const result = parseSwiftTestingIssueLine( '✘ Test "Some test" recorded an issue: Something went wrong', diff --git a/src/utils/swift-testing-line-parsers.ts b/src/utils/swift-testing-line-parsers.ts index 93836a0d..889c9039 100644 --- a/src/utils/swift-testing-line-parsers.ts +++ b/src/utils/swift-testing-line-parsers.ts @@ -91,7 +91,7 @@ export function parseSwiftTestingResultLine(line: string): ParsedTestCase | null export function parseSwiftTestingIssueLine(line: string): ParsedFailureDiagnostic | null { // Match with location -- handle both aka suffix and parameterized argument values before "at" const locationRegex = new RegExp( - `^[✘] Test "(.+)"${OPTIONAL_AKA} recorded an issue(?:\\s+with \\d+ argument values?[^:]*?)? at (.+?):(\\d+):\\d+: (.+)$`, + `^[✘] Test "(.+)"${OPTIONAL_AKA} recorded an issue(?:\\s+with \\d+ argument values?.*?)? at (.+?):(\\d+):\\d+: (.+)$`, 'u', ); const locationMatch = line.match(locationRegex); From 3c7fe5f858f77bb3e0b52c84dd73e6064707079f Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 16:14:31 +0100 Subject: [PATCH 13/15] test: add failing test for parameterized caseCount in xcodebuild event parser --- .../__tests__/xcodebuild-event-parser.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/utils/__tests__/xcodebuild-event-parser.test.ts b/src/utils/__tests__/xcodebuild-event-parser.test.ts index 56ef0b27..4ed3db74 100644 --- a/src/utils/__tests__/xcodebuild-event-parser.test.ts +++ b/src/utils/__tests__/xcodebuild-event-parser.test.ts @@ -279,6 +279,21 @@ describe('xcodebuild-event-parser', () => { expect(types).toContain('test-failure'); }); + it('increments counts by caseCount for parameterized Swift Testing results', () => { + const events = collectEvents('TEST', [ + { + source: 'stdout', + text: '✔ Test "Parameterized test" with 3 test cases passed after 0.001 seconds.\n', + }, + ]); + + const progress = events.filter((e) => e.type === 'test-progress'); + expect(progress).toHaveLength(1); + if (progress[0].type === 'test-progress') { + expect(progress[0].completed).toBe(3); + } + }); + it('skips Test Suite and Testing started noise lines without emitting events', () => { const events = collectEvents('TEST', [ { source: 'stdout', text: "Test Suite 'All tests' started at 2025-01-01 00:00:00.000.\n" }, From ce10d3009867e0f8fcef2b781b6c35f6c8078c07 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 16:15:09 +0100 Subject: [PATCH 14/15] fix: use caseCount for parameterized test progress in both event parsers recordTestCaseResult in xcodebuild-event-parser hardcoded += 1 for all count increments, ignoring the caseCount field from parameterized Swift Testing results. Also made the XCTest fallback path in the Swift Testing event parser consistent. --- src/utils/swift-testing-event-parser.ts | 7 ++++--- src/utils/xcodebuild-event-parser.ts | 8 +++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/utils/swift-testing-event-parser.ts b/src/utils/swift-testing-event-parser.ts index 6f4135ac..aca7d9cb 100644 --- a/src/utils/swift-testing-event-parser.ts +++ b/src/utils/swift-testing-event-parser.ts @@ -147,12 +147,13 @@ export function createSwiftTestingEventParser( // XCTest: Test Case '...' passed/failed (for mixed output from `swift test`) const xcTestCase = parseTestCaseLine(line); if (xcTestCase) { - completedCount += 1; + const xcIncrement = xcTestCase.caseCount ?? 1; + completedCount += xcIncrement; if (xcTestCase.status === 'failed') { - failedCount += 1; + failedCount += xcIncrement; } if (xcTestCase.status === 'skipped') { - skippedCount += 1; + skippedCount += xcIncrement; } emitTestProgress(); return; diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts index cf3ae0b9..f298ae53 100644 --- a/src/utils/xcodebuild-event-parser.ts +++ b/src/utils/xcodebuild-event-parser.ts @@ -234,10 +234,12 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb suiteName?: string; testName?: string; durationText?: string; + caseCount?: number; }): void { - completedCount += 1; + const increment = testCase.caseCount ?? 1; + completedCount += increment; if (testCase.status === 'failed') { - failedCount += 1; + failedCount += increment; applyFailureDuration( testCase.suiteName, testCase.testName, @@ -245,7 +247,7 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb ); } if (testCase.status === 'skipped') { - skippedCount += 1; + skippedCount += increment; } emitTestProgress(); } From 7c13ea909556e6db7d6f564d3d7eca05fc816a35 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 16:39:51 +0100 Subject: [PATCH 15/15] fix: remove dead noise patterns and rename durationText to displayDurationText on ParsedTotals The Resolve Package Graph and Resolved source packages noise patterns were unreachable dead code -- resolveStageFromLine matches them first and returns a build-stage event. Renamed ParsedTotals.durationText to displayDurationText to make clear it's a display string not parseable by parseDurationMs (the XCTest totals format is '1.234 (1.235) seconds' which doesn't parse). --- src/utils/__tests__/swift-testing-line-parsers.test.ts | 6 +++--- src/utils/swift-testing-line-parsers.ts | 4 ++-- src/utils/xcodebuild-event-parser.ts | 2 -- src/utils/xcodebuild-line-parsers.ts | 4 ++-- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/utils/__tests__/swift-testing-line-parsers.test.ts b/src/utils/__tests__/swift-testing-line-parsers.test.ts index d771e39b..f4ccf9e9 100644 --- a/src/utils/__tests__/swift-testing-line-parsers.test.ts +++ b/src/utils/__tests__/swift-testing-line-parsers.test.ts @@ -200,7 +200,7 @@ describe('Swift Testing line parsers', () => { expect(result).toEqual({ executed: 6, failed: 1, - durationText: '0.001s', + displayDurationText: '0.001s', }); }); @@ -211,7 +211,7 @@ describe('Swift Testing line parsers', () => { expect(result).toEqual({ executed: 5, failed: 0, - durationText: '0.003s', + displayDurationText: '0.003s', }); }); @@ -222,7 +222,7 @@ describe('Swift Testing line parsers', () => { expect(result).toEqual({ executed: 5, failed: 3, - durationText: '0.001s', + displayDurationText: '0.001s', }); }); diff --git a/src/utils/swift-testing-line-parsers.ts b/src/utils/swift-testing-line-parsers.ts index 889c9039..49ba54e4 100644 --- a/src/utils/swift-testing-line-parsers.ts +++ b/src/utils/swift-testing-line-parsers.ts @@ -140,7 +140,7 @@ export function parseSwiftTestingRunSummary(line: string): ParsedTotals | null { } const total = Number(match[1]); - const durationText = `${match[2]}s`; + const displayDurationText = `${match[2]}s`; // Swift Testing reports "issues" not "failed tests" -- a single test can produce // multiple issues (e.g. multiple #expect failures). This is the best available @@ -150,7 +150,7 @@ export function parseSwiftTestingRunSummary(line: string): ParsedTotals | null { const issueMatch = line.match(/with (\d+) issues?/u); const failed = issueMatch ? Number(issueMatch[1]) : 0; - return { executed: total, failed, durationText }; + return { executed: total, failed, displayDurationText }; } /** diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts index f298ae53..51e763ae 100644 --- a/src/utils/xcodebuild-event-parser.ts +++ b/src/utils/xcodebuild-event-parser.ts @@ -74,8 +74,6 @@ const IGNORED_NOISE_PATTERNS = [ /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+\s+xcodebuild\[.+\]\s+Writing error result bundle to\s+/u, /^Build settings from command line:$/u, /^(?:COMPILER_INDEX_STORE_ENABLE|ONLY_ACTIVE_ARCH)\s*=\s*.+$/u, - /^Resolve Package Graph$/u, - /^Resolved source packages:$/u, /^\s*[A-Za-z0-9_.-]+:\s+https?:\/\/.+$/u, /^--- xcodebuild: WARNING: Using the first of multiple matching destinations:$/u, /^\{\s*platform:.+\}$/u, diff --git a/src/utils/xcodebuild-line-parsers.ts b/src/utils/xcodebuild-line-parsers.ts index 4f5ba0c6..da62280e 100644 --- a/src/utils/xcodebuild-line-parsers.ts +++ b/src/utils/xcodebuild-line-parsers.ts @@ -32,7 +32,7 @@ export interface ParsedTestCase { export interface ParsedTotals { executed: number; failed: number; - durationText?: string; + displayDurationText?: string; } export interface ParsedFailureDiagnostic { @@ -104,7 +104,7 @@ export function parseTotalsLine(line: string): ParsedTotals | null { if (!match) { return null; } - return { executed: Number(match[1]), failed: Number(match[2]), durationText: match[3] }; + return { executed: Number(match[1]), failed: Number(match[2]), displayDurationText: match[3] }; } export function parseFailureDiagnostic(line: string): ParsedFailureDiagnostic | null {