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..f4ccf9e9 --- /dev/null +++ b/src/utils/__tests__/swift-testing-line-parsers.test.ts @@ -0,0 +1,289 @@ +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 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 with case count', () => { + 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', + 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, + }); + }); + + 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 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 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', + rawName: 'Disabled test', + testName: 'Disabled test', + }); + }); + + 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(); + }); + }); + + 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 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 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', + ); + expect(result).toEqual({ + rawTestName: 'Some test', + testName: 'Some test', + message: 'Something went wrong', + }); + }); + + 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(); + }); + }); + + 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, + displayDurationText: '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, + displayDurationText: '0.003s', + }); + }); + + 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, + displayDurationText: '0.001s', + }); + }); + + 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 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(); + }); + }); + + 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..4ed3db74 --- /dev/null +++ b/src/utils/__tests__/xcodebuild-event-parser.test.ts @@ -0,0 +1,307 @@ +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('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" }, + { 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..fd8dd3f3 --- /dev/null +++ b/src/utils/__tests__/xcodebuild-run-state.test.ts @@ -0,0 +1,407 @@ +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('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({ + 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..aca7d9cb --- /dev/null +++ b/src/utils/swift-testing-event-parser.ts @@ -0,0 +1,228 @@ +import type { PipelineEvent } from '../types/pipeline-events.ts'; +import { + parseSwiftTestingResultLine, + parseSwiftTestingIssueLine, + parseSwiftTestingRunSummary, + parseSwiftTestingContinuationLine, +} from './swift-testing-line-parsers.ts'; +import { + parseTestCaseLine, + parseTotalsLine, + parseFailureDiagnostic, + parseDurationMs, +} 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: { + suiteName?: string; + testName?: string; + message: string; + location?: string; + } | null = null; + + function flushPendingIssue(): void { + if (!lastIssueDiagnostic) { + return; + } + onEvent({ + type: 'test-failure', + timestamp: now(), + operation: 'TEST', + suite: lastIssueDiagnostic.suiteName, + 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; + } + + // 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; + const increment = stResult.caseCount ?? 1; + completedCount += increment; + failedCount += increment; + emitTestProgress(); + return; + } + + flushPendingIssue(); + + // Swift Testing issue line: ✘ Test "Name" recorded an issue at file:line:col: message + const issue = parseSwiftTestingIssueLine(line); + if (issue) { + lastIssueDiagnostic = { + suiteName: issue.suiteName, + testName: issue.testName, + message: issue.message, + location: issue.location, + }; + return; + } + + // Swift Testing result line: ✔/✘/◇ Test "Name" passed/failed/skipped (non-failure or no pending issue) + if (stResult) { + const increment = stResult.caseCount ?? 1; + completedCount += increment; + if (stResult.status === 'failed') { + failedCount += increment; + } + if (stResult.status === 'skipped') { + skippedCount += increment; + } + 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) { + const xcIncrement = xcTestCase.caseCount ?? 1; + completedCount += xcIncrement; + if (xcTestCase.status === 'failed') { + failedCount += xcIncrement; + } + if (xcTestCase.status === 'skipped') { + skippedCount += xcIncrement; + } + 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..49ba54e4 --- /dev/null +++ b/src/utils/swift-testing-line-parsers.ts @@ -0,0 +1,191 @@ +import { + type ParsedTestCase, + type ParsedFailureDiagnostic, + type ParsedTotals, + 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 (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" (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 passedRegex = new RegExp( + `^[✔] Test "(.+)"${OPTIONAL_AKA}${OPTIONAL_PARAMETERIZED} passed after ([\\d.]+) seconds\\.?$`, + 'u', + ); + const passedMatch = line.match(passedRegex); + if (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 }), + }; + } + + const failedRegex = new RegExp( + `^[✘] Test "(.+)"${OPTIONAL_AKA}${OPTIONAL_PARAMETERIZED} failed after ([\\d.]+) seconds`, + 'u', + ); + const failedMatch = line.match(failedRegex); + if (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 }), + }; + } + + // 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); + return { + status: 'skipped', + rawName, + suiteName, + testName, + }; + } + + return null; +} + +/** + * Parse a Swift Testing issue line. + * + * 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 { + // 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); + return { + rawTestName, + suiteName, + testName, + location: `${filePath}:${lineNumber}`, + message, + }; + } + + // 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); + 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 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 + // 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; + + return { executed: total, failed, displayDurationText }; +} + +/** + * 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..51e763ae --- /dev/null +++ b/src/utils/xcodebuild-event-parser.ts @@ -0,0 +1,429 @@ +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 [Ss]uite .+ 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, + /^\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, + /^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) { + pendingFailureDurations.delete(key); + 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); + pendingFailureDurations.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; + caseCount?: number; + }): void { + const increment = testCase.caseCount ?? 1; + completedCount += increment; + if (testCase.status === 'failed') { + failedCount += increment; + applyFailureDuration( + testCase.suiteName, + testCase.testName, + parseDurationMs(testCase.durationText), + ); + } + if (testCase.status === 'skipped') { + skippedCount += increment; + } + 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 [Ss]uite /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..da62280e --- /dev/null +++ b/src/utils/xcodebuild-line-parsers.ts @@ -0,0 +1,174 @@ +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; + caseCount?: number; +} + +export interface ParsedTotals { + executed: number; + failed: number; + displayDurationText?: 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.slice(0, -1).join('/'), testName: slashParts.at(-1)! }; + } + + 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]), displayDurationText: 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 { + // 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; + return { + location: `${filePath}:${lineNumber}`, + message, + renderedLine: line, + }; + } + + // 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; + } + 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..1ea7fbb9 --- /dev/null +++ b/src/utils/xcodebuild-run-state.ts @@ -0,0 +1,257 @@ +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(); + const suite = normalizeTestIdentifier(event.suite); + const test = normalizeTestIdentifier(event.test); + + if (normalizedLocation) { + // 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 `${suite}|${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, + events: [...state.events], + milestones: [...state.milestones], + warnings: [...state.warnings], + errors: [...state.errors], + testFailures: [...state.testFailures], + }; + }, + + snapshot(): Readonly { + return { + ...state, + events: [...state.events], + milestones: [...state.milestones], + warnings: [...state.warnings], + errors: [...state.errors], + testFailures: [...state.testFailures], + }; + }, + + highestStageRank(): number { + return highestRank; + }, + }; +}