diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b94ad0..d1d3b75e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Added `xcodebuildmcp upgrade` command to check for updates and upgrade in place. Supports `--check` (report-only) and `--yes`/`-y` (skip confirmation). Detects install method (Homebrew, npm-global, npx) and queries the appropriate channel source (`brew info`, `npm view`, or GitHub Releases) for the latest version. Non-interactive environments exit 1 when an auto-upgrade is possible but `--yes` was not supplied. - Added platform selection step to the `xcodebuildmcp setup` wizard. You now choose which platforms you are developing for (macOS, iOS, tvOS, watchOS, visionOS) before selecting workflows. Based on the selection, the wizard automatically recommends the appropriate workflow set ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). +- Added `XCODEBUILDMCP_CWD` environment variable to override the working directory used for project-config discovery and path resolution. Useful when the MCP server is launched by a host that can configure environment variables but not the spawn directory (e.g. MCP Inspector). Supports `~/` expansion. Programmatic `cwd` (passed via the bootstrap API) takes precedence; falls back to the original directory with a warning if the path can't be entered. +- Added per-test timing output for test runs ([#339](https://github.com/getsentry/XcodeBuildMCP/pull/339) by [@codeman9](https://github.com/codeman9)). Per-test results are always included in JSON/structured output as a new `testCases` field on `xcodebuildmcp.output.test-result` (suite, test, status, durationMs). Text rendering is opt-in: set the `showTestTiming` config option (or `XCODEBUILDMCP_SHOW_TEST_TIMING=1`) to render a `Test Results:` block of passed/failed/skipped cases with durations before the test summary. Works uniformly across CLI, JSON, and MCP output. Parameterized Swift Testing groups still surface as a single aggregate entry because xcodebuild does not emit per-case names or durations for them. ### Changed diff --git a/schemas/structured-output/xcodebuildmcp.output.session-profile/1.schema.json b/schemas/structured-output/xcodebuildmcp.output.session-profile/1.schema.json index 8603e812..3aa189c8 100644 --- a/schemas/structured-output/xcodebuildmcp.output.session-profile/1.schema.json +++ b/schemas/structured-output/xcodebuildmcp.output.session-profile/1.schema.json @@ -33,6 +33,9 @@ }, "currentProfile": { "type": "string" + }, + "persisted": { + "type": "boolean" } }, "required": [ diff --git a/schemas/structured-output/xcodebuildmcp.output.test-result/1.schema.json b/schemas/structured-output/xcodebuildmcp.output.test-result/1.schema.json index b7c2cb47..2c187d17 100644 --- a/schemas/structured-output/xcodebuildmcp.output.test-result/1.schema.json +++ b/schemas/structured-output/xcodebuildmcp.output.test-result/1.schema.json @@ -113,6 +113,36 @@ } }, "required": [] + }, + "testCases": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "suite": { + "type": "string" + }, + "test": { + "type": "string" + }, + "status": { + "enum": [ + "passed", + "failed", + "skipped" + ] + }, + "durationMs": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "test", + "status" + ] + } } }, "required": [ diff --git a/src/cli/__tests__/register-tool-commands.test.ts b/src/cli/__tests__/register-tool-commands.test.ts index 53e1106f..2d9a48a5 100644 --- a/src/cli/__tests__/register-tool-commands.test.ts +++ b/src/cli/__tests__/register-tool-commands.test.ts @@ -49,6 +49,7 @@ const baseRuntimeConfig: ResolvedRuntimeConfig = { experimentalWorkflowDiscovery: false, disableSessionDefaults: true, disableXcodeAutoSync: false, + showTestTiming: false, uiDebuggerGuardMode: 'error', incrementalBuildsEnabled: false, dapRequestTimeoutMs: 30_000, diff --git a/src/cli/__tests__/session-defaults.test.ts b/src/cli/__tests__/session-defaults.test.ts index 48426f40..e2a99f0c 100644 --- a/src/cli/__tests__/session-defaults.test.ts +++ b/src/cli/__tests__/session-defaults.test.ts @@ -17,6 +17,7 @@ describe('CLI session defaults', () => { experimentalWorkflowDiscovery: false, disableSessionDefaults: true, disableXcodeAutoSync: false, + showTestTiming: false, uiDebuggerGuardMode: 'error', incrementalBuildsEnabled: false, dapRequestTimeoutMs: 30_000, diff --git a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts index 664e0230..a8a405d0 100644 --- a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts @@ -9,7 +9,11 @@ import { schema, sessionUseDefaultsProfileLogic, } from '../session_use_defaults_profile.ts'; -import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; +import { + allText, + createMockToolHandlerContext, + runLogic, +} from '../../../../test-utils/test-helpers.ts'; describe('session-use-defaults-profile tool', () => { beforeEach(() => { @@ -83,11 +87,11 @@ describe('session-use-defaults-profile tool', () => { sessionStore.setActiveProfile('ios'); sessionStore.setActiveProfile(null); - const result = await runLogic(() => - sessionUseDefaultsProfileLogic({ profile: 'ios', persist: true }), - ); - expect(result.isError).toBeFalsy(); - expect(allText(result)).toContain('Persisted active profile selection'); + const { ctx, result, run } = createMockToolHandlerContext(); + await run(() => sessionUseDefaultsProfileLogic({ profile: 'ios', persist: true })); + expect(result.isError()).toBe(false); + expect(result.text()).toContain('Persisted active profile selection'); + expect(ctx.structuredOutput?.result).toMatchObject({ persisted: true }); expect(writes).toHaveLength(1); const parsed = parseYaml(writes[0].content) as { activeSessionDefaultsProfile?: string }; expect(parsed.activeSessionDefaultsProfile).toBe('ios'); diff --git a/src/rendering/render-items.ts b/src/rendering/render-items.ts index fd5ede96..7e94d188 100644 --- a/src/rendering/render-items.ts +++ b/src/rendering/render-items.ts @@ -107,6 +107,15 @@ export interface TestFailureRenderItem { durationMs?: number; } +export interface TestCaseResultRenderItem { + type: 'test-case-result'; + operation: 'TEST'; + suite?: string; + test: string; + status: 'passed' | 'failed' | 'skipped'; + durationMs?: number; +} + export interface SummaryRenderItem { type: 'summary'; operation?: XcodebuildOperation; @@ -134,4 +143,5 @@ export type RenderItem = | TestDiscoveryRenderItem | TestProgressRenderItem | TestFailureRenderItem + | TestCaseResultRenderItem | SummaryRenderItem; diff --git a/src/rendering/render.ts b/src/rendering/render.ts index 112211f6..83004da0 100644 --- a/src/rendering/render.ts +++ b/src/rendering/render.ts @@ -1,6 +1,7 @@ import type { AnyFragment } from '../types/domain-fragments.ts'; import type { NextStep } from '../types/common.ts'; import { sessionStore } from '../utils/session-store.ts'; +import { getConfig } from '../utils/config-store.ts'; import { createCliTextRenderer, renderCliTextTranscript, @@ -103,18 +104,21 @@ function createBaseRenderSession(hooks: RenderSessionHooks): RenderSession { function createTextRenderSession(): RenderSession { const suppressWarnings = sessionStore.get('suppressWarnings'); + const showTestTiming = getConfig().showTestTiming; return createBaseRenderSession({ finalize: (input) => renderCliTextTranscript({ ...input, suppressWarnings: suppressWarnings ?? false, + showTestTiming, }), }); } function createRawRenderSession(): RenderSession { const suppressWarnings = sessionStore.get('suppressWarnings'); + const showTestTiming = getConfig().showTestTiming; return createBaseRenderSession({ onEmit: (fragment) => { @@ -136,6 +140,7 @@ function createRawRenderSession(): RenderSession { nextSteps: input.nextSteps, nextStepsRuntime: input.nextStepsRuntime, suppressWarnings: suppressWarnings ?? false, + showTestTiming, }); if (text) { process.stdout.write(text); @@ -146,7 +151,13 @@ function createRawRenderSession(): RenderSession { } function createCliTextRenderSession(options: { interactive: boolean }): RenderSession { - const renderer = createCliTextRenderer(options); + const suppressWarnings = sessionStore.get('suppressWarnings'); + const showTestTiming = getConfig().showTestTiming; + const renderer = createCliTextRenderer({ + ...options, + suppressWarnings: suppressWarnings ?? false, + showTestTiming, + }); return createBaseRenderSession({ onEmit: (fragment) => renderer.onFragment(fragment), diff --git a/src/runtime/__tests__/bootstrap-runtime.test.ts b/src/runtime/__tests__/bootstrap-runtime.test.ts index 58386a1d..cb013712 100644 --- a/src/runtime/__tests__/bootstrap-runtime.test.ts +++ b/src/runtime/__tests__/bootstrap-runtime.test.ts @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; +import os from 'node:os'; const { scheduleSimulatorDefaultsRefreshMock } = vi.hoisted(() => ({ scheduleSimulatorDefaultsRefreshMock: vi.fn(), @@ -143,4 +144,66 @@ describe('bootstrapRuntime', () => { expect(sessionStore.getAll().scheme).toBe('IOSScheme'); expect(sessionStore.getAll().simulatorName).toBe('iPhone 17'); }); + + describe('XCODEBUILDMCP_CWD env override', () => { + let chdirSpy: ReturnType | null = null; + let originalEnvValue: string | undefined; + + beforeEach(() => { + originalEnvValue = process.env.XCODEBUILDMCP_CWD; + chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => undefined); + }); + + afterEach(() => { + chdirSpy?.mockRestore(); + chdirSpy = null; + if (originalEnvValue === undefined) { + delete process.env.XCODEBUILDMCP_CWD; + } else { + process.env.XCODEBUILDMCP_CWD = originalEnvValue; + } + }); + + it('chdirs to env-var value when opts.cwd is undefined', async () => { + process.env.XCODEBUILDMCP_CWD = '/explicit/project/dir'; + await bootstrapRuntime({ runtime: 'cli', fs: createFsWithSessionDefaults() }); + expect(chdirSpy).toHaveBeenCalledWith('/explicit/project/dir'); + }); + + it('does not chdir when opts.cwd is provided (caller wins)', async () => { + process.env.XCODEBUILDMCP_CWD = '/should/be/ignored'; + await bootstrapRuntime({ runtime: 'cli', cwd, fs: createFsWithSessionDefaults() }); + expect(chdirSpy).not.toHaveBeenCalled(); + }); + + it('expands a leading ~/ to the home directory', async () => { + process.env.XCODEBUILDMCP_CWD = '~/Developer/project'; + await bootstrapRuntime({ runtime: 'cli', fs: createFsWithSessionDefaults() }); + const calledWith = chdirSpy?.mock.calls[0]?.[0] as string; + expect(calledWith.endsWith('/Developer/project')).toBe(true); + expect(calledWith.startsWith('~')).toBe(false); + }); + + it('expands a bare ~ to the home directory', async () => { + process.env.XCODEBUILDMCP_CWD = '~'; + await bootstrapRuntime({ runtime: 'cli', fs: createFsWithSessionDefaults() }); + expect(chdirSpy).toHaveBeenCalledWith(os.homedir()); + }); + + it('falls back gracefully when chdir throws', async () => { + process.env.XCODEBUILDMCP_CWD = '/nonexistent'; + chdirSpy?.mockImplementation(() => { + throw new Error('ENOENT'); + }); + await expect( + bootstrapRuntime({ runtime: 'cli', fs: createFsWithSessionDefaults() }), + ).resolves.toBeDefined(); + }); + + it('is a no-op when env var is unset', async () => { + delete process.env.XCODEBUILDMCP_CWD; + await bootstrapRuntime({ runtime: 'cli', cwd, fs: createFsWithSessionDefaults() }); + expect(chdirSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/runtime/bootstrap-runtime.ts b/src/runtime/bootstrap-runtime.ts index 3bb0a238..7b062df2 100644 --- a/src/runtime/bootstrap-runtime.ts +++ b/src/runtime/bootstrap-runtime.ts @@ -10,6 +10,7 @@ import { getDefaultFileSystemExecutor } from '../utils/command.ts'; import { log } from '../utils/logger.ts'; import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; import { scheduleSimulatorDefaultsRefresh } from '../utils/simulator-defaults-refresh.ts'; +import { expandHomePrefix } from '../utils/path.ts'; export type RuntimeKind = 'cli' | 'daemon' | 'mcp'; @@ -94,10 +95,31 @@ function logHydrationResult(hydration: MCPSessionHydrationResult): void { ); } +function resolveCwdOverride(): string | undefined { + const raw = process.env.XCODEBUILDMCP_CWD; + if (!raw) { + return undefined; + } + return expandHomePrefix(raw); +} + export async function bootstrapRuntime( opts: BootstrapRuntimeOptions, ): Promise { process.env.XCODEBUILDMCP_RUNTIME = opts.runtime; + const cwdOverride = opts.cwd === undefined ? resolveCwdOverride() : undefined; + if (cwdOverride !== undefined) { + try { + process.chdir(cwdOverride); + } catch (error) { + log( + 'warn', + `XCODEBUILDMCP_CWD points at "${cwdOverride}" but chdir failed: ${ + error instanceof Error ? error.message : String(error) + }. Falling back to existing cwd.`, + ); + } + } const cwd = opts.cwd ?? process.cwd(); const fs = opts.fs ?? getDefaultFileSystemExecutor(); diff --git a/src/snapshot-tests/__fixtures__/json/device/test--failure.json b/src/snapshot-tests/__fixtures__/json/device/test--failure.json index 8caa3ad1..21ad4f33 100644 --- a/src/snapshot-tests/__fixtures__/json/device/test--failure.json +++ b/src/snapshot-tests/__fixtures__/json/device/test--failure.json @@ -57,6 +57,146 @@ "location": "/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286" } ] - } + }, + "testCases": [ + { + "suite": "CalculatorAppTests", + "test": "testAddition", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testAppLaunch", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculationPerformance", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorOperationsEnum", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServiceBasicOperation", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServiceChainedOperations", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServiceClear", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServiceCreation", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServiceFailure", + "status": "failed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServicePublicInterface", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServicePublicProperties", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testComplexCalculationWorkflow", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testContentViewInitialization", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testDivisionByZero", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testLargeNumberInputPerformance", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testLargeNumbers", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testMultipleDecimalPointsHandling", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testPercentageCalculation", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testRepeatedEquals", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testSignToggle", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testStateConsistencyAfterOperations", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testStateConsistencyWithDecimalNumbers", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "IntentionalFailureTests", + "test": "test", + "status": "failed", + "durationMs": 0 + } + ] } } diff --git a/src/snapshot-tests/__fixtures__/json/device/test--success.json b/src/snapshot-tests/__fixtures__/json/device/test--success.json index 229c14be..33411647 100644 --- a/src/snapshot-tests/__fixtures__/json/device/test--success.json +++ b/src/snapshot-tests/__fixtures__/json/device/test--success.json @@ -44,6 +44,14 @@ "warnings": [], "errors": [], "testFailures": [] - } + }, + "testCases": [ + { + "suite": "CalculatorAppTests", + "test": "testAddition", + "status": "passed", + "durationMs": 0 + } + ] } } diff --git a/src/snapshot-tests/__fixtures__/json/macos/test--failure.json b/src/snapshot-tests/__fixtures__/json/macos/test--failure.json index 23e544fc..a0051113 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/test--failure.json +++ b/src/snapshot-tests/__fixtures__/json/macos/test--failure.json @@ -52,6 +52,32 @@ "location": "MCPTestTests.swift:11" } ] - } + }, + "testCases": [ + { + "suite": "MCPTestsXCTests", + "test": "testAppNameIsCorrect()", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "MCPTestsXCTests", + "test": "testDeliberateFailure()", + "status": "failed", + "durationMs": 0 + }, + { + "suite": "MCPTestTests", + "test": "appNameIsCorrect()", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "MCPTestTests", + "test": "deliberateFailure()", + "status": "failed", + "durationMs": 0 + } + ] } } diff --git a/src/snapshot-tests/__fixtures__/json/macos/test--success.json b/src/snapshot-tests/__fixtures__/json/macos/test--success.json index 470a6007..07377f44 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/test--success.json +++ b/src/snapshot-tests/__fixtures__/json/macos/test--success.json @@ -44,6 +44,20 @@ "warnings": [], "errors": [], "testFailures": [] - } + }, + "testCases": [ + { + "suite": "MCPTestsXCTests", + "test": "testAppNameIsCorrect()", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "MCPTestTests", + "test": "appNameIsCorrect()", + "status": "passed", + "durationMs": 0 + } + ] } } diff --git a/src/snapshot-tests/__fixtures__/json/session-management/session-use-defaults-profile--persist-success.json b/src/snapshot-tests/__fixtures__/json/session-management/session-use-defaults-profile--persist-success.json new file mode 100644 index 00000000..0ba2023a --- /dev/null +++ b/src/snapshot-tests/__fixtures__/json/session-management/session-use-defaults-profile--persist-success.json @@ -0,0 +1,11 @@ +{ + "schema": "xcodebuildmcp.output.session-profile", + "schemaVersion": "1", + "didError": false, + "error": null, + "data": { + "previousProfile": "(default)", + "currentProfile": "MyCustomProfile", + "persisted": true + } +} diff --git a/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json b/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json index 5dad8c81..b78adc03 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json @@ -55,6 +55,146 @@ "location": "/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286" } ] - } + }, + "testCases": [ + { + "suite": "CalculatorAppTests", + "test": "testAddition", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testAppLaunch", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculationPerformance", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorOperationsEnum", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServiceBasicOperation", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServiceChainedOperations", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServiceClear", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServiceCreation", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServiceFailure", + "status": "failed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServicePublicInterface", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServicePublicProperties", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testComplexCalculationWorkflow", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testContentViewInitialization", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testDivisionByZero", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testLargeNumberInputPerformance", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testLargeNumbers", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testMultipleDecimalPointsHandling", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testPercentageCalculation", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testRepeatedEquals", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testSignToggle", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testStateConsistencyAfterOperations", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testStateConsistencyWithDecimalNumbers", + "status": "passed", + "durationMs": 0 + }, + { + "suite": "IntentionalFailureTests", + "test": "test", + "status": "failed", + "durationMs": 0 + } + ] } } diff --git a/src/snapshot-tests/__fixtures__/json/simulator/test--success.json b/src/snapshot-tests/__fixtures__/json/simulator/test--success.json index a41fb5c0..da4f7fb3 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/test--success.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/test--success.json @@ -42,6 +42,14 @@ "warnings": [], "errors": [], "testFailures": [] - } + }, + "testCases": [ + { + "suite": "CalculatorAppTests", + "test": "testAddition", + "status": "passed", + "durationMs": 0 + } + ] } } diff --git a/src/snapshot-tests/__fixtures__/json/swift-package/test--failure.json b/src/snapshot-tests/__fixtures__/json/swift-package/test--failure.json index db9ca242..b453e41e 100644 --- a/src/snapshot-tests/__fixtures__/json/swift-package/test--failure.json +++ b/src/snapshot-tests/__fixtures__/json/swift-package/test--failure.json @@ -40,6 +40,44 @@ "location": "SimpleTests.swift:57" } ] - } + }, + "testCases": [ + { + "test": "Array operations", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Basic math operations", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Basic truth assertions", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Optional handling", + "status": "passed", + "durationMs": 0 + }, + { + "test": "String operations", + "status": "passed", + "durationMs": 0 + }, + { + "test": "test", + "status": "failed", + "durationMs": 0 + }, + { + "suite": "CalculatorAppTests", + "test": "testCalculatorServiceFailure", + "status": "failed", + "durationMs": 0 + } + ] } } diff --git a/src/snapshot-tests/__fixtures__/json/swift-package/test--success.json b/src/snapshot-tests/__fixtures__/json/swift-package/test--success.json index 572b782a..b68e5495 100644 --- a/src/snapshot-tests/__fixtures__/json/swift-package/test--success.json +++ b/src/snapshot-tests/__fixtures__/json/swift-package/test--success.json @@ -27,6 +27,13 @@ "warnings": [], "errors": [], "testFailures": [] - } + }, + "testCases": [ + { + "test": "Basic truth assertions", + "status": "passed", + "durationMs": 0 + } + ] } } diff --git a/src/snapshot-tests/json-normalize.ts b/src/snapshot-tests/json-normalize.ts index 99bab381..94744242 100644 --- a/src/snapshot-tests/json-normalize.ts +++ b/src/snapshot-tests/json-normalize.ts @@ -31,7 +31,9 @@ function normalizeString(value: string, key?: string, path: string[] = []): stri function normalizeNumber(path: string[], key: string | undefined, value: number): number { switch (key) { case 'durationMs': - return path.at(-2) === 'summary' ? 1234 : value; + if (path.at(-2) === 'summary') return 1234; + if (path.includes('testCases')) return 0; + return value; case 'processId': case 'pid': return 99999; @@ -65,7 +67,15 @@ function normalizeValue(value: unknown, path: string[] = []): unknown { } if (Array.isArray(value)) { - return value.map((item, index) => normalizeValue(item, [...path, String(index)])); + const normalized = value.map((item, index) => normalizeValue(item, [...path, String(index)])); + if (path.at(-1) === 'testCases') { + return [...normalized].sort((a, b) => { + const ka = `${(a as { suite?: string }).suite ?? ''}|${(a as { test?: string }).test ?? ''}`; + const kb = `${(b as { suite?: string }).suite ?? ''}|${(b as { test?: string }).test ?? ''}`; + return ka.localeCompare(kb); + }); + } + return normalized; } if (isRecord(value)) { diff --git a/src/types/domain-fragments.ts b/src/types/domain-fragments.ts index 150a94c9..3d5ae45f 100644 --- a/src/types/domain-fragments.ts +++ b/src/types/domain-fragments.ts @@ -164,6 +164,16 @@ export interface TestProgressFragment { skipped: number; } +export interface TestCaseResultFragment { + kind: 'test-result'; + fragment: 'test-case-result'; + operation: 'TEST'; + suite?: string; + test: string; + status: 'passed' | 'failed' | 'skipped'; + durationMs?: number; +} + // --------------------------------------------------------------------------- // Per-kind unions // --------------------------------------------------------------------------- @@ -180,6 +190,7 @@ export type TestDomainFragment = | TestDiscoveryFragment | TestFailureFragment | TestProgressFragment + | TestCaseResultFragment | BuildInvocationFragment; // --------------------------------------------------------------------------- diff --git a/src/types/domain-results.ts b/src/types/domain-results.ts index 8f0f14c4..36ccb878 100644 --- a/src/types/domain-results.ts +++ b/src/types/domain-results.ts @@ -314,6 +314,12 @@ export interface ProjectListSummary extends StatusSummary { export interface ScaffoldSummary extends StatusSummary { platform: 'iOS' | 'macOS'; } +export interface TestCaseResult { + suite?: string; + test: string; + status: 'passed' | 'failed' | 'skipped'; + durationMs?: number; +} export interface TestSummary extends BuildLikeSummary { counts?: Counts; } @@ -560,6 +566,7 @@ export type SessionProfileDomainResult = ToolDomainResultBase & { kind: 'session-profile'; previousProfile: string; currentProfile: string; + persisted?: boolean; }; export type SimulatorActionResultDomainResult = ToolDomainResultBase & { kind: 'simulator-action-result'; @@ -591,6 +598,7 @@ export type TestResultDomainResult = ToolDomainResultBase & { artifacts: TestResultArtifacts; diagnostics: TestDiagnostics; tests?: TestSelectionInfo; + testCases?: readonly TestCaseResult[]; }; export type UiActionResultDomainResult = ToolDomainResultBase & { kind: 'ui-action-result'; diff --git a/src/utils/__tests__/config-store.test.ts b/src/utils/__tests__/config-store.test.ts index 78dda5a2..436204a6 100644 --- a/src/utils/__tests__/config-store.test.ts +++ b/src/utils/__tests__/config-store.test.ts @@ -268,6 +268,7 @@ describe('config-store', () => { XCODEBUILDMCP_SCHEME: 'MyApp', XCODEBUILDMCP_PLATFORM: 'macOS', XCODEBUILDMCP_SUPPRESS_WARNINGS: 'true', + XCODEBUILDMCP_SHOW_TEST_TIMING: 'true', XCODEBUILDMCP_DERIVED_DATA_PATH: '/tmp/dd', XCODEBUILDMCP_USE_LATEST_OS: 'true', XCODEBUILDMCP_ARCH: 'arm64', @@ -282,6 +283,7 @@ describe('config-store', () => { expect(config.sessionDefaults?.scheme).toBe('MyApp'); expect(config.sessionDefaults?.platform).toBe('macOS'); expect(config.sessionDefaults?.suppressWarnings).toBe(true); + expect(config.showTestTiming).toBe(true); expect(config.sessionDefaults?.derivedDataPath).toBe('/tmp/dd'); expect(config.sessionDefaults?.useLatestOS).toBe(true); expect(config.sessionDefaults?.arch).toBe('arm64'); diff --git a/src/utils/__tests__/xcodebuild-event-parser.test.ts b/src/utils/__tests__/xcodebuild-event-parser.test.ts index feed57d3..6eb454a4 100644 --- a/src/utils/__tests__/xcodebuild-event-parser.test.ts +++ b/src/utils/__tests__/xcodebuild-event-parser.test.ts @@ -89,6 +89,64 @@ describe('xcodebuild-event-parser', () => { expect(progressEvents[2]).toMatchObject({ completed: 3, failed: 1, skipped: 0 }); }); + it('emits test-case-result events with status, suite, test, and duration', () => { + 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.250 seconds)\n" }, + ]); + + const cases = events.filter((e) => e.fragment === 'test-case-result'); + expect(cases).toHaveLength(2); + expect(cases[0]).toMatchObject({ + fragment: 'test-case-result', + operation: 'TEST', + suite: 'Suite', + test: 'testA', + status: 'passed', + durationMs: 1, + }); + expect(cases[1]).toMatchObject({ + fragment: 'test-case-result', + operation: 'TEST', + suite: 'Suite', + test: 'testB', + status: 'failed', + durationMs: 250, + }); + }); + + it('emits test-case-result events for Swift Testing passed/failed lines', () => { + const events = collectEvents('TEST', [ + { source: 'stdout', text: '✔ Test "passingTest()" passed after 0.005 seconds.\n' }, + { + source: 'stdout', + text: '✘ Test "failingTest()" failed after 0.010 seconds with 1 issue.\n', + }, + ]); + + const cases = events.filter((e) => e.fragment === 'test-case-result'); + expect(cases).toHaveLength(2); + expect(cases[0]).toMatchObject({ + status: 'passed', + test: 'passingTest()', + durationMs: 5, + }); + expect(cases[1]).toMatchObject({ + status: 'failed', + test: 'failingTest()', + durationMs: 10, + }); + }); + + it('does not emit test-case-result for BUILD operation', () => { + const events = collectEvents('BUILD', [ + { source: 'stdout', text: "Test Case '-[Suite testA]' passed (0.001 seconds)\n" }, + ]); + + const cases = events.filter((e) => e.fragment === 'test-case-result'); + expect(cases).toHaveLength(0); + }); + it('emits test-progress from totals line', () => { const events = collectEvents('TEST', [ { diff --git a/src/utils/__tests__/xcodebuild-run-state.test.ts b/src/utils/__tests__/xcodebuild-run-state.test.ts index 8080221e..3a5723d8 100644 --- a/src/utils/__tests__/xcodebuild-run-state.test.ts +++ b/src/utils/__tests__/xcodebuild-run-state.test.ts @@ -380,6 +380,39 @@ describe('xcodebuild-run-state', () => { expect(state.snapshot().testFailures).toHaveLength(2); }); + it('collects test-case-result fragments on the snapshot', () => { + const forwarded: DomainFragment[] = []; + const state = createXcodebuildRunState({ + operation: 'TEST', + onEvent: (e) => forwarded.push(e), + }); + + state.push({ + kind: 'test-result', + fragment: 'test-case-result', + operation: 'TEST', + suite: 'Suite', + test: 'testA', + status: 'passed', + durationMs: 5, + }); + state.push({ + kind: 'test-result', + fragment: 'test-case-result', + operation: 'TEST', + suite: 'Suite', + test: 'testB', + status: 'failed', + durationMs: 12, + }); + + const snap = state.snapshot(); + expect(snap.testCaseResults).toHaveLength(2); + expect(snap.testCaseResults[0]).toMatchObject({ test: 'testA', status: 'passed' }); + expect(snap.testCaseResults[1]).toMatchObject({ test: 'testB', status: 'failed' }); + expect(forwarded).toHaveLength(2); + }); + it('forwards test discovery events without storing additional state', () => { const forwarded: DomainFragment[] = []; const state = createXcodebuildRunState({ diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index f0bc26ae..76c3afc4 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -19,6 +19,7 @@ export type RuntimeConfigOverrides = Partial<{ experimentalWorkflowDiscovery: boolean; disableSessionDefaults: boolean; disableXcodeAutoSync: boolean; + showTestTiming: boolean; uiDebuggerGuardMode: UiDebuggerGuardMode; incrementalBuildsEnabled: boolean; dapRequestTimeoutMs: number; @@ -43,6 +44,7 @@ export type ResolvedRuntimeConfig = { experimentalWorkflowDiscovery: boolean; disableSessionDefaults: boolean; disableXcodeAutoSync: boolean; + showTestTiming: boolean; uiDebuggerGuardMode: UiDebuggerGuardMode; incrementalBuildsEnabled: boolean; dapRequestTimeoutMs: number; @@ -77,6 +79,7 @@ const DEFAULT_CONFIG: ResolvedRuntimeConfig = { experimentalWorkflowDiscovery: false, disableSessionDefaults: false, disableXcodeAutoSync: false, + showTestTiming: false, uiDebuggerGuardMode: 'error', incrementalBuildsEnabled: false, dapRequestTimeoutMs: 30_000, @@ -194,6 +197,8 @@ function readEnvConfig(env: NodeJS.ProcessEnv): RuntimeConfigOverrides { parseBoolean(env.XCODEBUILDMCP_DISABLE_XCODE_AUTO_SYNC), ); + setIfDefined(config, 'showTestTiming', parseBoolean(env.XCODEBUILDMCP_SHOW_TEST_TIMING)); + setIfDefined( config, 'uiDebuggerGuardMode', @@ -481,6 +486,13 @@ function resolveConfig(opts: { envConfig, fallback: DEFAULT_CONFIG.disableXcodeAutoSync, }), + showTestTiming: resolveFromLayers({ + key: 'showTestTiming', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.showTestTiming, + }), uiDebuggerGuardMode: resolveFromLayers({ key: 'uiDebuggerGuardMode', overrides: opts.overrides, diff --git a/src/utils/renderers/__tests__/cli-text-renderer.test.ts b/src/utils/renderers/__tests__/cli-text-renderer.test.ts index 52d360df..8ba6a13f 100644 --- a/src/utils/renderers/__tests__/cli-text-renderer.test.ts +++ b/src/utils/renderers/__tests__/cli-text-renderer.test.ts @@ -582,4 +582,48 @@ describe('cli-text-renderer', () => { expect(output).toContain('5 tests passed, 1 skipped'); expect(output).toContain('Build Logs: /tmp/test.log'); }); + + it('omits per-test results by default and renders them when showTestTiming is true', () => { + const fragments = [ + { + kind: 'test-result' as const, + fragment: 'test-case-result' as const, + operation: 'TEST' as const, + suite: 'Suite', + test: 'testA', + status: 'passed' as const, + durationMs: 5, + }, + { + kind: 'test-result' as const, + fragment: 'test-case-result' as const, + operation: 'TEST' as const, + suite: 'Suite', + test: 'testB', + status: 'failed' as const, + durationMs: 12, + }, + { + kind: 'test-result' as const, + fragment: 'build-summary' as const, + operation: 'TEST' as const, + status: 'FAILED' as const, + totalTests: 2, + passedTests: 1, + failedTests: 1, + skippedTests: 0, + durationMs: 17, + }, + ]; + + const withoutFlag = renderCliTextTranscript({ items: fragments }); + expect(withoutFlag).not.toContain('Test Results:'); + expect(withoutFlag).not.toContain('Suite/testA'); + + const withFlag = renderCliTextTranscript({ items: fragments, showTestTiming: true }); + expect(withFlag).toContain('Test Results:'); + expect(withFlag).toContain('Suite/testA'); + expect(withFlag).toContain('Suite/testB'); + expect(withFlag).toContain('(0.005s)'); + }); }); diff --git a/src/utils/renderers/__tests__/event-formatting.test.ts b/src/utils/renderers/__tests__/event-formatting.test.ts index 0ce15047..ae2d54ec 100644 --- a/src/utils/renderers/__tests__/event-formatting.test.ts +++ b/src/utils/renderers/__tests__/event-formatting.test.ts @@ -11,6 +11,7 @@ import { formatTransientBuildStageEvent, formatStatusLineEvent, formatDetailTreeEvent, + formatTestCaseResults, formatTransientStatusLineEvent, } from '../event-formatting.ts'; @@ -257,4 +258,43 @@ describe('event formatting', () => { expect(rendered).toContain(' - XCTAssertEqual failed'); expect(rendered).toContain(' - Expected 4, got 5'); }); + + it('formats per-test-case results with status icons and durations', () => { + const rendered = formatTestCaseResults([ + { + type: 'test-case-result', + operation: 'TEST', + suite: 'Suite', + test: 'testA', + status: 'passed', + durationMs: 5, + }, + { + type: 'test-case-result', + operation: 'TEST', + suite: 'Suite', + test: 'testB', + status: 'failed', + durationMs: 250, + }, + { + type: 'test-case-result', + operation: 'TEST', + test: 'testC', + status: 'skipped', + }, + ]); + + expect(rendered).toContain('Test Results:'); + expect(rendered).toContain('Suite/testA'); + expect(rendered).toContain('(0.005s)'); + expect(rendered).toContain('Suite/testB'); + expect(rendered).toContain('(0.250s)'); + expect(rendered).toContain('testC'); + expect(rendered).not.toContain('Suite/testC'); + }); + + it('returns empty string when no test results provided', () => { + expect(formatTestCaseResults([])).toBe(''); + }); }); diff --git a/src/utils/renderers/cli-text-renderer.ts b/src/utils/renderers/cli-text-renderer.ts index 8eb2ab4e..fd1c23b5 100644 --- a/src/utils/renderers/cli-text-renderer.ts +++ b/src/utils/renderers/cli-text-renderer.ts @@ -7,6 +7,7 @@ import type { CompilerWarningRenderItem, RenderItem, StatusRenderItem, + TestCaseResultRenderItem, TestFailureRenderItem, } from '../../rendering/render-items.ts'; import { deriveBuildLikeTitle, invocationRequestToHeaderParams } from '../xcodebuild-pipeline.ts'; @@ -36,6 +37,7 @@ import { formatGroupedTestFailures, formatSummaryEvent, formatNextStepsEvent, + formatTestCaseResults, formatTestDiscoveryEvent, } from './event-formatting.ts'; import { @@ -65,11 +67,13 @@ interface CliTextProcessorOptions { interactive: boolean; sink: CliTextSink; suppressWarnings: boolean; + showTestTiming: boolean; } interface CliTextRendererOptions { interactive: boolean; suppressWarnings?: boolean; + showTestTiming?: boolean; } export interface CliTextTranscriptInput { @@ -78,6 +82,7 @@ export interface CliTextTranscriptInput { nextSteps?: readonly NextStep[]; nextStepsRuntime?: 'cli' | 'daemon' | 'mcp'; suppressWarnings?: boolean; + showTestTiming?: boolean; } interface XcodebuildParserState { @@ -89,10 +94,11 @@ interface XcodebuildParserState { type RunStateEvent = Parameters[0]; function createCliTextProcessor(options: CliTextProcessorOptions): TranscriptRenderer { - const { interactive, sink, suppressWarnings } = options; + const { interactive, sink, suppressWarnings, showTestTiming } = options; const groupedCompilerErrors: CompilerErrorRenderItem[] = []; const groupedWarnings: CompilerWarningRenderItem[] = []; const groupedTestFailures: TestFailureRenderItem[] = []; + const collectedTestCaseResults: TestCaseResultRenderItem[] = []; const parserStates = new Map(); let pendingTransientRuntimeLine: string | null = null; let diagnosticBaseDir: string | null = null; @@ -267,6 +273,13 @@ function createCliTextProcessor(options: CliTextProcessorOptions): TranscriptRen break; } + case 'test-case-result': { + if (showTestTiming) { + collectedTestCaseResults.push(item); + } + break; + } + case 'summary': { const renderedDiagnostics = flushGroupedDiagnostics(item.status === 'FAILED'); @@ -274,6 +287,14 @@ function createCliTextProcessor(options: CliTextProcessorOptions): TranscriptRen flushPendingTransientRuntimeLine(); } + if (showTestTiming && collectedTestCaseResults.length > 0) { + const block = formatTestCaseResults(collectedTestCaseResults); + if (block) { + writeSection(block); + } + collectedTestCaseResults.length = 0; + } + writeSection(formatSummaryEvent(item)); lastVisibleEventType = 'summary'; lastStatusLineLevel = null; @@ -414,6 +435,7 @@ function createCliTextProcessor(options: CliTextProcessorOptions): TranscriptRen nextStepsRuntime = undefined; parserStates.clear(); sawProgressNextSteps = false; + collectedTestCaseResults.length = 0; }, }; } @@ -424,6 +446,7 @@ export function createCliTextRenderer(options: CliTextRendererOptions): Transcri return createCliTextProcessor({ interactive: options.interactive, suppressWarnings: options.suppressWarnings ?? false, + showTestTiming: options.showTestTiming ?? false, sink: { clearTransient(): void { reporter.clear(); @@ -446,6 +469,7 @@ export function renderCliTextTranscript(input: CliTextTranscriptInput = {}): str const renderer = createCliTextProcessor({ interactive: false, suppressWarnings: input.suppressWarnings ?? false, + showTestTiming: input.showTestTiming ?? false, sink: { clearTransient(): void {}, updateTransient(): void {}, @@ -549,6 +573,15 @@ function domainFragmentToRenderItem(fragment: AnyFragment): RenderItem | null { ...(fragment.location !== undefined ? { location: fragment.location } : {}), ...(fragment.durationMs !== undefined ? { durationMs: fragment.durationMs } : {}), }; + case 'test-case-result': + return { + type: 'test-case-result', + operation: fragment.operation, + ...(fragment.suite !== undefined ? { suite: fragment.suite } : {}), + test: fragment.test, + status: fragment.status, + ...(fragment.durationMs !== undefined ? { durationMs: fragment.durationMs } : {}), + }; case 'status': return { type: 'status', level: fragment.level, message: fragment.message }; case 'test-progress': diff --git a/src/utils/renderers/domain-result-text.ts b/src/utils/renderers/domain-result-text.ts index fc33398c..7a24a7e0 100644 --- a/src/utils/renderers/domain-result-text.ts +++ b/src/utils/renderers/domain-result-text.ts @@ -2234,6 +2234,18 @@ export function renderDomainResultTextItems( if (result.kind === 'build-run-result') { items.push(...createBuildRunSyntheticStepItems(result)); } + if (result.kind === 'test-result' && result.testCases && result.testCases.length > 0) { + for (const testCase of result.testCases) { + items.push({ + type: 'test-case-result', + operation: 'TEST', + ...(testCase.suite !== undefined ? { suite: testCase.suite } : {}), + test: testCase.test, + status: testCase.status, + ...(testCase.durationMs !== undefined ? { durationMs: testCase.durationMs } : {}), + }); + } + } const summary = createSummaryBlock(result); if (summary) { items.push(summary); diff --git a/src/utils/renderers/event-formatting.ts b/src/utils/renderers/event-formatting.ts index c6c62e2c..76b55622 100644 --- a/src/utils/renderers/event-formatting.ts +++ b/src/utils/renderers/event-formatting.ts @@ -12,6 +12,7 @@ import type { SectionRenderItem, StatusRenderItem, TableRenderItem, + TestCaseResultRenderItem, TestDiscoveryRenderItem, TestFailureRenderItem, } from '../../rendering/render-items.ts'; @@ -550,6 +551,28 @@ export function formatNextStepsEvent(event: NextStepsTextBlock, runtime: 'cli' | return renderNextStepsSection(event.steps, runtime); } +export function formatTestCaseResults(items: readonly TestCaseResultRenderItem[]): string { + if (items.length === 0) { + return ''; + } + + const statusIcon: Record = { + passed: '\u{2705}', + failed: '\u{274C}', + skipped: '\u{23ED}\u{FE0F}', + }; + + const lines: string[] = ['Test Results:']; + for (const item of items) { + const icon = statusIcon[item.status]; + const duration = + item.durationMs !== undefined ? ` (${(item.durationMs / 1000).toFixed(3)}s)` : ''; + const name = item.suite ? `${item.suite}/${item.test}` : item.test; + lines.push(` ${icon} ${name}${duration}`); + } + return lines.join('\n'); +} + export function formatGroupedTestFailures( events: TestFailureRenderItem[], options?: DiagnosticFormattingOptions, diff --git a/src/utils/runtime-config-schema.ts b/src/utils/runtime-config-schema.ts index 87816409..190cc0e6 100644 --- a/src/utils/runtime-config-schema.ts +++ b/src/utils/runtime-config-schema.ts @@ -11,6 +11,7 @@ export const runtimeConfigFileSchema = z experimentalWorkflowDiscovery: z.boolean().optional(), disableSessionDefaults: z.boolean().optional(), disableXcodeAutoSync: z.boolean().optional(), + showTestTiming: z.boolean().optional(), uiDebuggerGuardMode: z.enum(['error', 'warn', 'off']).optional(), incrementalBuildsEnabled: z.boolean().optional(), dapRequestTimeoutMs: z.number().int().positive().optional(), diff --git a/src/utils/swift-testing-event-parser.ts b/src/utils/swift-testing-event-parser.ts deleted file mode 100644 index 26d8715d..00000000 --- a/src/utils/swift-testing-event-parser.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { DomainFragment } from '../types/domain-fragments.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: (fragment: DomainFragment) => void; -} - -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({ - kind: 'test-result', - fragment: 'test-failure', - operation: 'TEST', - suite: lastIssueDiagnostic.suiteName, - test: lastIssueDiagnostic.testName, - message: lastIssueDiagnostic.message, - location: lastIssueDiagnostic.location, - }); - lastIssueDiagnostic = null; - } - - function emitTestProgress(): void { - onEvent({ - kind: 'test-result', - fragment: 'test-progress', - operation: 'TEST', - completed: completedCount, - failed: failedCount, - skipped: skippedCount, - }); - } - - function processLine(rawLine: string): void { - const line = rawLine.trim(); - if (!line) { - flushPendingIssue(); - return; - } - - const continuation = parseSwiftTestingContinuationLine(line); - if (continuation && lastIssueDiagnostic) { - lastIssueDiagnostic.message += `\n${continuation}`; - return; - } - - const stResult = parseSwiftTestingResultLine(line); - if (stResult && stResult.status === 'failed' && lastIssueDiagnostic) { - const durationMs = parseDurationMs(stResult.durationText); - onEvent({ - kind: 'test-result', - fragment: 'test-failure', - 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(); - - const issue = parseSwiftTestingIssueLine(line); - if (issue) { - lastIssueDiagnostic = { - suiteName: issue.suiteName, - testName: issue.testName, - message: issue.message, - location: issue.location, - }; - return; - } - - if (stResult) { - const increment = stResult.caseCount ?? 1; - completedCount += increment; - if (stResult.status === 'failed') { - failedCount += increment; - } - if (stResult.status === 'skipped') { - skippedCount += increment; - } - emitTestProgress(); - return; - } - - const stSummary = parseSwiftTestingRunSummary(line); - if (stSummary) { - completedCount = stSummary.executed; - failedCount = stSummary.failed; - emitTestProgress(); - return; - } - - 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; - } - - const xcTotals = parseTotalsLine(line); - if (xcTotals) { - completedCount = xcTotals.executed; - failedCount = xcTotals.failed; - emitTestProgress(); - return; - } - - const xcFailure = parseFailureDiagnostic(line); - if (xcFailure) { - onEvent({ - kind: 'test-result', - fragment: 'test-failure', - operation: 'TEST', - suite: xcFailure.suiteName, - test: xcFailure.testName, - message: xcFailure.message, - location: xcFailure.location, - }); - return; - } - - if (/^[◇] Test run started/u.test(line) || /^Testing started$/u.test(line)) { - onEvent({ - kind: 'test-result', - fragment: 'build-stage', - 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/xcodebuild-domain-results.ts b/src/utils/xcodebuild-domain-results.ts index 415d37c9..65e94f73 100644 --- a/src/utils/xcodebuild-domain-results.ts +++ b/src/utils/xcodebuild-domain-results.ts @@ -376,6 +376,12 @@ export function createTestDomainResult(options: { const skipped = state.skippedTests; const passed = Math.max(0, state.completedTests - failed - skipped); const testSelectionInfo = createTestSelectionInfo(options.preflight); + const testCases = state.testCaseResults.map((fragment) => ({ + ...(fragment.suite !== undefined ? { suite: fragment.suite } : {}), + test: fragment.test, + status: fragment.status, + ...(fragment.durationMs !== undefined ? { durationMs: fragment.durationMs } : {}), + })); const result: TestResultDomainResult = { kind: 'test-result', request: options.request, @@ -398,6 +404,7 @@ export function createTestDomainResult(options: { artifacts: options.artifacts, ...(testSelectionInfo ? { tests: testSelectionInfo } : {}), diagnostics: createTestDiagnostics(state, !options.succeeded, options.fallbackErrorMessages), + ...(testCases.length > 0 ? { testCases } : {}), }; return result; diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts index eacc9467..e32f5f39 100644 --- a/src/utils/xcodebuild-event-parser.ts +++ b/src/utils/xcodebuild-event-parser.ts @@ -10,6 +10,7 @@ import { parseBuildErrorDiagnostic, parseDurationMs, isBuildErrorDiagnosticLine, + type ParsedTestCase, } from './xcodebuild-line-parsers.ts'; import { parseXcodebuildSwiftTestingLine, @@ -223,26 +224,29 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb }); } - function recordTestCaseResult(testCase: { - status: string; - suiteName?: string; - testName?: string; - durationText?: string; - caseCount?: number; - }): void { + function recordTestCaseResult(testCase: ParsedTestCase): void { const increment = testCase.caseCount ?? 1; completedCount += increment; + const durationMs = parseDurationMs(testCase.durationText); + if (testCase.status === 'failed') { failedCount += increment; - applyFailureDuration( - testCase.suiteName, - testCase.testName, - parseDurationMs(testCase.durationText), - ); - } - if (testCase.status === 'skipped') { + applyFailureDuration(testCase.suiteName, testCase.testName, durationMs); + } else if (testCase.status === 'skipped') { skippedCount += increment; } + + if (operation === 'TEST') { + onEvent({ + kind: 'test-result', + fragment: 'test-case-result', + operation: 'TEST', + ...(testCase.suiteName !== undefined ? { suite: testCase.suiteName } : {}), + test: testCase.testName, + status: testCase.status, + ...(durationMs !== undefined ? { durationMs } : {}), + }); + } emitTestProgress(); } diff --git a/src/utils/xcodebuild-pipeline.ts b/src/utils/xcodebuild-pipeline.ts index fc8dd18f..1fea7284 100644 --- a/src/utils/xcodebuild-pipeline.ts +++ b/src/utils/xcodebuild-pipeline.ts @@ -58,6 +58,7 @@ function isRunStateFragment(fragment: DomainFragment): fragment is RunStateEvent case 'test-discovery': case 'test-progress': case 'test-failure': + case 'test-case-result': return true; default: return false; diff --git a/src/utils/xcodebuild-run-state.ts b/src/utils/xcodebuild-run-state.ts index 89470810..efe43d51 100644 --- a/src/utils/xcodebuild-run-state.ts +++ b/src/utils/xcodebuild-run-state.ts @@ -3,6 +3,7 @@ import type { BuildSummaryFragment, CompilerDiagnosticFragment, DomainFragment, + TestCaseResultFragment, TestDiscoveryFragment, TestFailureFragment, TestProgressFragment, @@ -15,7 +16,8 @@ type XcodebuildRunStateFragment = | CompilerDiagnosticFragment | TestDiscoveryFragment | TestFailureFragment - | TestProgressFragment; + | TestProgressFragment + | TestCaseResultFragment; export interface XcodebuildRunState { operation: XcodebuildOperation; @@ -24,6 +26,7 @@ export interface XcodebuildRunState { warnings: CompilerDiagnosticFragment[]; errors: CompilerDiagnosticFragment[]; testFailures: TestFailureFragment[]; + testCaseResults: TestCaseResultFragment[]; completedTests: number; failedTests: number; skippedTests: number; @@ -124,6 +127,7 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu warnings: [], errors: [], testFailures: [], + testCaseResults: [], completedTests: 0, failedTests: 0, skippedTests: 0, @@ -191,6 +195,12 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu break; } + case 'test-case-result': { + state.testCaseResults.push(fragment); + accept(fragment); + break; + } + case 'test-progress': { state.completedTests = fragment.completed; state.failedTests = fragment.failed; @@ -238,6 +248,7 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu warnings: [...state.warnings], errors: [...state.errors], testFailures: [...state.testFailures], + testCaseResults: [...state.testCaseResults], }; }, @@ -248,6 +259,7 @@ export function createXcodebuildRunState(options: RunStateOptions): XcodebuildRu warnings: [...state.warnings], errors: [...state.errors], testFailures: [...state.testFailures], + testCaseResults: [...state.testCaseResults], }; }, diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts index a2563d28..5f20979a 100644 --- a/src/visibility/__tests__/exposure.test.ts +++ b/src/visibility/__tests__/exposure.test.ts @@ -26,6 +26,7 @@ function createDefaultConfig( experimentalWorkflowDiscovery: false, disableSessionDefaults: false, disableXcodeAutoSync: false, + showTestTiming: false, uiDebuggerGuardMode: 'error', incrementalBuildsEnabled: false, dapRequestTimeoutMs: 30000, diff --git a/src/visibility/__tests__/predicate-registry.test.ts b/src/visibility/__tests__/predicate-registry.test.ts index 1250e153..25158bd4 100644 --- a/src/visibility/__tests__/predicate-registry.test.ts +++ b/src/visibility/__tests__/predicate-registry.test.ts @@ -19,6 +19,7 @@ function createDefaultConfig( experimentalWorkflowDiscovery: false, disableSessionDefaults: false, disableXcodeAutoSync: false, + showTestTiming: false, uiDebuggerGuardMode: 'error', incrementalBuildsEnabled: false, dapRequestTimeoutMs: 30000,