diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 39662b5a..ca6b9f3c 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -12,7 +12,7 @@ For full details see [README.md](README.md) and [docs/ARCHITECTURE.md](docs/ARCH ## 1. Security Checklist — Critical * No hard-coded secrets, tokens or DSNs. -* All shell commands must flow through `CommandExecutor` with validated arguments (no direct `child_process` calls). +* MCP tool logic functions that orchestrate long-running processes with sub-processes (e.g., `xcodebuild`) must flow through `CommandExecutor` with validated arguments. Standalone utility modules that invoke simple, short-lived commands may use direct `child_process`/`fs` imports and standard vitest mocking. * Paths must be sanitised via helpers in `src/utils/validation.ts`. * Sentry breadcrumbs / logs must **NOT** include user PII. @@ -22,7 +22,7 @@ For full details see [README.md](README.md) and [docs/ARCHITECTURE.md](docs/ARCH | Rule | Quick diff heuristic | |------|----------------------| -| Dependency injection only | New `child_process` \| `fs` import ⇒ **critical** | +| Dependency injection for tool logic | New `child_process` \| `fs` import in MCP tool logic ⇒ **warning** (check if process is complex/long-running) | | Handler / Logic split | `handler` > 20 LOC or contains branching ⇒ **critical** | | Plugin auto-registration | Manual `registerTool(...)` / `registerResource(...)` ⇒ **critical** | @@ -45,7 +45,8 @@ export const handler = (p: FooBarParams) => fooBarLogic(p); ## 3. Testing Checklist -* **External-boundary rule**: Use `createMockExecutor` / `createMockFileSystemExecutor` for command execution and filesystem side effects. +* **External-boundary rule for tool logic**: Use `createMockExecutor` / `createMockFileSystemExecutor` for complex process orchestration (xcodebuild, multi-step pipelines) in MCP tool logic functions. +* **Simple utilities**: Standalone utility modules with simple command calls can use direct imports and standard vitest mocking. * **Internal mocking is allowed**: `vi.mock`, `vi.fn`, `vi.spyOn`, and `.mock*` are acceptable for internal modules/collaborators. * Each tool must have tests covering happy-path **and** at least one failure path. * Avoid the `any` type unless justified with an inline comment. @@ -66,7 +67,7 @@ export const handler = (p: FooBarParams) => fooBarLogic(p); |--------------|--------------------| | Complex logic in `handler` | Move to `*Logic` function | | Re-implementing logging | Use `src/utils/logger.ts` | -| Direct `fs` / `child_process` usage | Inject `FileSystemExecutor` / `CommandExecutor` | +| Direct `fs` / `child_process` in tool logic orchestrating complex processes | Inject `FileSystemExecutor` / `CommandExecutor` | | Chained re-exports | Export directly from source | --- @@ -74,7 +75,7 @@ export const handler = (p: FooBarParams) => fooBarLogic(p); ### How Bugbot Can Verify Rules 1. **External-boundary violations**: confirm tests use injected executors/filesystem for external side effects. -2. **DI compliance**: search for direct `child_process` / `fs` imports outside approved patterns. +2. **DI compliance**: check direct `child_process` / `fs` imports in MCP tool logic; standalone utilities with simple commands are acceptable. 3. **Docs accuracy**: compare `docs/TOOLS.md` against `src/mcp/tools/**`. 4. **Style**: ensure ESLint and Prettier pass (`npm run lint`, `npm run format:check`). diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index 7d72cb13..5a0c7583 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -418,13 +418,13 @@ Not all parts are required for every tool. For example, `swift_package_build` ha ### Testing Principles -XcodeBuildMCP uses a **Dependency Injection (DI)** pattern for testing external boundaries (command execution, filesystem, and other side effects). Vitest mocking libraries (`vi.mock`, `vi.fn`, etc.) are acceptable for internal collaborators when needed. This keeps tests robust while preserving deterministic behavior at external boundaries. +XcodeBuildMCP uses a **Dependency Injection (DI)** pattern for MCP tool logic functions that orchestrate complex, long-running processes with sub-processes (e.g., `xcodebuild`), where standard vitest mocking produces race conditions. Standalone utility modules with simple commands may use direct `child_process`/`fs` imports and standard vitest mocking. Vitest mocking libraries (`vi.mock`, `vi.fn`, etc.) are also acceptable for internal collaborators. For detailed guidelines, see the [Testing Guide](TESTING.md). ### Test Structure Example -Tests inject mock "executors" for external interactions like command-line execution or file system access. This allows for deterministic testing of tool logic without mocking the implementation itself. The project provides helper functions like `createMockExecutor` and `createMockFileSystemExecutor` in `src/test-utils/mock-executors.ts` to facilitate this pattern. +Tests for MCP tool logic inject mock "executors" for complex process orchestration (e.g., xcodebuild). This allows for deterministic testing without race conditions from non-deterministic sub-process ordering. The project provides helper functions like `createMockExecutor` and `createMockFileSystemExecutor` in `src/test-utils/mock-executors.ts`. Standalone utility modules with simple commands use standard vitest mocking. ```typescript import { describe, it, expect } from 'vitest'; diff --git a/docs/dev/CODE_QUALITY.md b/docs/dev/CODE_QUALITY.md index a3aa7554..648c2904 100644 --- a/docs/dev/CODE_QUALITY.md +++ b/docs/dev/CODE_QUALITY.md @@ -53,17 +53,18 @@ XcodeBuildMCP enforces several architectural patterns that cannot be expressed t ### 1. Dependency Injection Pattern -**Rule**: All tools must use dependency injection for external interactions. +**Rule**: MCP tool logic functions that orchestrate complex, long-running processes with sub-processes (e.g., `xcodebuild`) must use dependency injection for external interactions. This is because standard vitest mocking produces race conditions when sub-process ordering is non-deterministic. + +Standalone utility modules that invoke simple, short-lived commands (e.g., `xcrun devicectl list`, `xcrun xcresulttool get`) may use direct `child_process`/`fs` imports and be tested with standard vitest mocking. ✅ **Allowed**: -- `createMockExecutor()` for command execution mocking -- `createMockFileSystemExecutor()` for file system mocking -- Logic functions accepting `executor?: CommandExecutor` parameter +- `createMockExecutor()` / `createMockFileSystemExecutor()` for complex process orchestration in tool logic +- Logic functions accepting `executor?: CommandExecutor` parameter for xcodebuild and similar pipelines +- Direct `child_process`/`fs` imports in standalone utility modules with simple commands, tested via vitest mocking ❌ **Forbidden**: -- Direct calls to `execSync`, `spawn`, or `exec` in production code - Testing handler functions directly -- Real external side effects in unit tests (xcodebuild/xcrun/filesystem writes outside mocks) +- Real external side effects in unit tests (real `xcodebuild`, `xcrun`, AXe, filesystem writes/reads outside test harness) ### 2. Handler Signature Compliance diff --git a/docs/dev/CONTRIBUTING.md b/docs/dev/CONTRIBUTING.md index 5289af9b..9a09a497 100644 --- a/docs/dev/CONTRIBUTING.md +++ b/docs/dev/CONTRIBUTING.md @@ -270,7 +270,7 @@ Before making changes, please familiarize yourself with: All contributions must adhere to the testing standards outlined in the [**XcodeBuildMCP Plugin Testing Guidelines (TESTING.md)**](TESTING.md). This is the canonical source of truth for all testing practices. **Key Principles (Summary):** -- **Dependency Injection for External Boundaries**: All external dependencies (command execution, file system access) must be injected into tool logic functions using the `CommandExecutor` and `FileSystemExecutor` patterns. +- **Dependency Injection for Complex Processes**: MCP tool logic functions that orchestrate complex, long-running processes with sub-processes (e.g., `xcodebuild`) must use injected `CommandExecutor` and `FileSystemExecutor` patterns. Standalone utility modules with simple commands may use direct imports and standard vitest mocking. - **Internal Mocking Is Allowed**: Vitest mocking (`vi.mock`, `vi.fn`, `vi.spyOn`, etc.) is acceptable for internal modules/collaborators. - **Test Production Code**: Tests must import and execute the actual tool logic, not mock implementations. - **Comprehensive Coverage**: Tests must cover input validation, command generation, and output processing. diff --git a/docs/dev/TESTING.md b/docs/dev/TESTING.md index c1e20e6e..4e11f722 100644 --- a/docs/dev/TESTING.md +++ b/docs/dev/TESTING.md @@ -20,11 +20,16 @@ This document provides comprehensive testing guidelines for XcodeBuildMCP plugin ### 🚨 CRITICAL: External Dependency Mocking Rules -### ABSOLUTE RULE: External side effects must use dependency injection utilities +### When to use dependency-injection executors -### Use dependency-injection mocks for EXTERNAL dependencies: -- `createMockExecutor()` / `createNoopExecutor()` for command execution (`xcrun`, `xcodebuild`, AXe, etc.) -- `createMockFileSystemExecutor()` / `createNoopFileSystemExecutor()` for file system interactions +`CommandExecutor` / `FileSystemExecutor` DI is required for **MCP tool logic functions** that orchestrate complex, long-running processes with sub-processes (e.g., `xcodebuild`, multi-step build pipelines). Standard vitest mocking produces race conditions with these because sub-process ordering is non-deterministic. + +- `createMockExecutor()` / `createNoopExecutor()` for command execution in tool logic +- `createMockFileSystemExecutor()` / `createNoopFileSystemExecutor()` for file system interactions in tool logic + +### When standard vitest mocking is fine + +Standalone utility modules that invoke simple, short-lived commands (e.g., `xcrun devicectl list`, `xcrun xcresulttool get`) may use direct `child_process`/`fs` imports and be tested with standard vitest mocking (`vi.fn`, `vi.mock`, `vi.spyOn`, etc.). This is simpler and perfectly adequate for deterministic, single-command utilities. ### Internal mocking guidance: - Vitest mocking (`vi.fn`, `vi.mock`, `vi.spyOn`, `.mockResolvedValue`, etc.) is allowed for internal modules and in-memory collaborators @@ -32,16 +37,15 @@ This document provides comprehensive testing guidelines for XcodeBuildMCP plugin ### Still forbidden: - Hitting real external systems in unit tests (real `xcodebuild`, `xcrun`, AXe, filesystem writes/reads outside test harness) -- Bypassing dependency injection for external effects ### OUR CORE PRINCIPLE -**Simple Rule**: Use dependency-injection mock executors for external boundaries; use Vitest mocking only for internal behavior. +**Simple Rule**: Use dependency-injection mock executors for complex process orchestration in tool logic; use standard vitest mocking for simple utility modules and internal behavior. **Why This Rule Exists**: -1. **Reliability**: External side effects stay deterministic and hermetic -2. **Clarity**: Internal collaboration assertions remain concise and readable -3. **Architectural Enforcement**: External boundaries are explicit in tool logic signatures +1. **Reliability**: Complex multi-process orchestration stays deterministic and hermetic via DI executors +2. **Simplicity**: Simple utilities use standard vitest mocking without unnecessary abstraction +3. **Architectural Enforcement**: External boundaries for complex processes are explicit in tool logic signatures 4. **Maintainability**: Tests fail for behavior regressions, not incidental environment differences ### Integration Testing with Dependency Injection @@ -111,7 +115,7 @@ Test → Plugin Handler → utilities → [DEPENDENCY INJECTION] createMockExecu ### Handler Requirements -All plugin handlers must support dependency injection: +MCP tool logic functions that orchestrate complex processes must support dependency injection: ```typescript export function tool_nameLogic( @@ -134,12 +138,9 @@ export default { }; ``` -**Important**: The dependency injection pattern applies to ALL handlers, including: -- Tool handlers -- Resource handlers -- Any future handler types (prompts, etc.) +**Important**: The dependency injection pattern applies to tool and resource handler logic that orchestrates complex, long-running processes (e.g., `xcodebuild`). Standalone utility modules with simple commands may use direct imports and standard vitest mocking. -Always use default parameter values (e.g., `= getDefaultCommandExecutor()`) to ensure production code works without explicit executor injection, while tests can override with mock executors. +Always use default parameter values (e.g., `= getDefaultCommandExecutor()`) in tool logic to ensure production code works without explicit executor injection, while tests can override with mock executors. ### Test Requirements diff --git a/src/test-utils/test-helpers.ts b/src/test-utils/test-helpers.ts new file mode 100644 index 00000000..c92e28dd --- /dev/null +++ b/src/test-utils/test-helpers.ts @@ -0,0 +1,204 @@ +/** + * Shared test helpers for extracting text content from tool responses. + */ + +import { expect } from 'vitest'; +import type { ToolHandlerContext, ImageAttachment } from '../rendering/types.ts'; +import type { PipelineEvent } from '../types/pipeline-events.ts'; +import type { ToolResponse, NextStepParamsMap } from '../types/common.ts'; +import type { ToolHandler } from '../utils/typed-tool-factory.ts'; +import { renderEvents } from '../rendering/render.ts'; +import { createRenderSession } from '../rendering/render.ts'; +import { handlerContextStorage } from '../utils/typed-tool-factory.ts'; + +/** + * Extract and join all text content items from a tool response. + */ +export function allText(result: { + content: ReadonlyArray<{ type: string; text?: string; [key: string]: unknown }>; +}): string { + return result.content + .filter( + (c): c is { type: 'text'; text: string } => c.type === 'text' && typeof c.text === 'string', + ) + .map((c) => c.text) + .join('\n'); +} + +/** + * Assert that a tool response represents a pending xcodebuild result + * with an optional next-step tool reference. + */ +export interface MockToolHandlerResult { + events: PipelineEvent[]; + attachments: ImageAttachment[]; + nextStepParams?: NextStepParamsMap; + text(): string; + isError(): boolean; +} + +export function createMockToolHandlerContext(): { + ctx: ToolHandlerContext; + result: MockToolHandlerResult; + run: (fn: () => Promise) => Promise; +} { + const events: PipelineEvent[] = []; + const attachments: ImageAttachment[] = []; + const ctx: ToolHandlerContext = { + emit: (event) => { + events.push(event); + }, + attach: (image) => { + attachments.push(image); + }, + }; + const resultObj: MockToolHandlerResult = { + events, + attachments, + get nextStepParams() { + return ctx.nextStepParams; + }, + text() { + return renderEvents(events, 'text'); + }, + isError() { + return events.some( + (e) => + (e.type === 'status-line' && e.level === 'error') || + (e.type === 'summary' && e.status === 'FAILED'), + ); + }, + }; + return { + ctx, + result: resultObj, + run: async (fn: () => Promise): Promise => { + return handlerContextStorage.run(ctx, fn); + }, + }; +} + +export async function runToolLogic(logic: () => Promise): Promise<{ + response: T; + result: MockToolHandlerResult; +}> { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + return { response, result }; +} + +export interface RunLogicResult { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: NextStepParamsMap; + attachments?: ImageAttachment[]; + text?: string; +} + +/** + * Run a tool's logic function in a mock handler context and return a + * ToolResponse-shaped result for backward-compatible test assertions. + */ +export async function runLogic(logic: () => Promise): Promise { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as RunLogicResult; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +} + +export interface CallHandlerResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; + nextStepParams?: NextStepParamsMap; +} + +/** + * Call a tool handler in test mode, providing a session context and + * returning a ToolResponse-shaped result for backward-compatible assertions. + */ +export async function callHandler( + handler: + | ToolHandler + | ((args: Record, ctx?: ToolHandlerContext) => Promise), + args: Record, +): Promise { + const session = createRenderSession('text'); + const ctx: ToolHandlerContext = { + emit: (event) => session.emit(event), + attach: (image) => session.attach(image), + }; + await handler(args, ctx); + const text = session.finalize(); + return { + content: text ? [{ type: 'text' as const, text }] : [], + isError: session.isError() || undefined, + nextStepParams: ctx.nextStepParams, + }; +} + +function isMockToolHandlerResult( + result: ToolResponse | MockToolHandlerResult, +): result is MockToolHandlerResult { + return 'events' in result && Array.isArray(result.events) && typeof result.text === 'function'; +} + +export function expectPendingBuildResponse( + result: ToolResponse | MockToolHandlerResult, + nextStepToolId?: string, +): void { + if (isMockToolHandlerResult(result)) { + expect(result.events.some((event) => event.type === 'summary')).toBe(true); + + if (nextStepToolId) { + expect(result.nextStepParams).toEqual( + expect.objectContaining({ + [nextStepToolId]: expect.any(Object), + }), + ); + } else { + expect(result.nextStepParams).toBeUndefined(); + } + return; + } + + expect(result.content).toEqual([]); + expect(result._meta).toEqual( + expect.objectContaining({ + pendingXcodebuild: expect.objectContaining({ + kind: 'pending-xcodebuild', + }), + }), + ); + + if (nextStepToolId) { + expect(result.nextStepParams).toEqual( + expect.objectContaining({ + [nextStepToolId]: expect.any(Object), + }), + ); + } else { + expect(result.nextStepParams).toBeUndefined(); + } +} diff --git a/src/types/common.ts b/src/types/common.ts index 8d49bccf..50481ff0 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -137,6 +137,7 @@ export interface PlatformBuildOptions { simulatorId?: string; deviceId?: string; useLatestOS?: boolean; + packageCachePath?: string; arch?: string; logPrefix: string; } diff --git a/src/utils/__tests__/build-preflight.test.ts b/src/utils/__tests__/build-preflight.test.ts new file mode 100644 index 00000000..88e7be8a --- /dev/null +++ b/src/utils/__tests__/build-preflight.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect } from 'vitest'; +import { displayPath, formatToolPreflight } from '../build-preflight.ts'; +import { DERIVED_DATA_DIR } from '../log-paths.ts'; + +const DISPLAY_DERIVED_DATA = displayPath(DERIVED_DATA_DIR); + +describe('formatToolPreflight', () => { + it('formats simulator build with workspace and simulator name', () => { + const result = formatToolPreflight({ + operation: 'Build', + scheme: 'MyApp', + workspacePath: '/path/to/MyApp.xcworkspace', + configuration: 'Debug', + platform: 'iOS Simulator', + simulatorName: 'iPhone 17', + }); + + expect(result).toBe( + [ + '\u{1F528} Build', + '', + ' Scheme: MyApp', + ' Workspace: /path/to/MyApp.xcworkspace', + ' Configuration: Debug', + ' Platform: iOS Simulator', + ' Simulator: iPhone 17', + ` Derived Data: ${DISPLAY_DERIVED_DATA}`, + '', + ].join('\n'), + ); + }); + + it('formats simulator build with project and simulator ID', () => { + const result = formatToolPreflight({ + operation: 'Build', + scheme: 'MyApp', + projectPath: '/path/to/MyApp.xcodeproj', + configuration: 'Release', + platform: 'iOS Simulator', + simulatorId: 'ABC-123-DEF', + }); + + expect(result).toBe( + [ + '\u{1F528} Build', + '', + ' Scheme: MyApp', + ' Project: /path/to/MyApp.xcodeproj', + ' Configuration: Release', + ' Platform: iOS Simulator', + ' Simulator: ABC-123-DEF', + ` Derived Data: ${DISPLAY_DERIVED_DATA}`, + '', + ].join('\n'), + ); + }); + + it('formats build & run with device ID only', () => { + const result = formatToolPreflight({ + operation: 'Build & Run', + scheme: 'MyApp', + projectPath: '/path/to/MyApp.xcodeproj', + configuration: 'Debug', + platform: 'iOS', + deviceId: 'DEVICE-UDID-123', + }); + + expect(result).toBe( + [ + '\u{1F680} Build & Run', + '', + ' Scheme: MyApp', + ' Project: /path/to/MyApp.xcodeproj', + ' Configuration: Debug', + ' Platform: iOS', + ' Device: DEVICE-UDID-123', + ` Derived Data: ${DISPLAY_DERIVED_DATA}`, + '', + ].join('\n'), + ); + }); + + it('formats build & run with device name and ID', () => { + const result = formatToolPreflight({ + operation: 'Build & Run', + scheme: 'MyApp', + projectPath: '/path/to/MyApp.xcodeproj', + configuration: 'Debug', + platform: 'iOS', + deviceId: 'DEVICE-UDID-123', + deviceName: "Cameron's iPhone", + }); + + expect(result).toBe( + [ + '\u{1F680} Build & Run', + '', + ' Scheme: MyApp', + ' Project: /path/to/MyApp.xcodeproj', + ' Configuration: Debug', + ' Platform: iOS', + " Device: Cameron's iPhone (DEVICE-UDID-123)", + ` Derived Data: ${DISPLAY_DERIVED_DATA}`, + '', + ].join('\n'), + ); + }); + + it('formats macOS build & run with the approved front-matter spacing', () => { + const result = formatToolPreflight({ + operation: 'Build & Run', + scheme: 'MacApp', + projectPath: '/path/to/MacApp.xcodeproj', + configuration: 'Debug', + platform: 'macOS', + }); + + expect(result).toBe( + [ + '\u{1F680} Build & Run', + '', + ' Scheme: MacApp', + ' Project: /path/to/MacApp.xcodeproj', + ' Configuration: Debug', + ' Platform: macOS', + ` Derived Data: ${DISPLAY_DERIVED_DATA}`, + '', + ].join('\n'), + ); + }); + + it('formats macOS build with architecture', () => { + const result = formatToolPreflight({ + operation: 'Build', + scheme: 'MyMacApp', + workspacePath: '/path/to/workspace.xcworkspace', + configuration: 'Debug', + platform: 'macOS', + arch: 'arm64', + }); + + expect(result).toBe( + [ + '\u{1F528} Build', + '', + ' Scheme: MyMacApp', + ' Workspace: /path/to/workspace.xcworkspace', + ' Configuration: Debug', + ' Platform: macOS', + ` Derived Data: ${DISPLAY_DERIVED_DATA}`, + ' Architecture: arm64', + '', + ].join('\n'), + ); + }); + + it('formats clean operation', () => { + const result = formatToolPreflight({ + operation: 'Clean', + scheme: 'MyApp', + projectPath: '/path/to/MyApp.xcodeproj', + configuration: 'Debug', + platform: 'iOS', + }); + + expect(result).toBe( + [ + '\u{1F9F9} Clean', + '', + ' Scheme: MyApp', + ' Project: /path/to/MyApp.xcodeproj', + ' Configuration: Debug', + ' Platform: iOS', + ` Derived Data: ${DISPLAY_DERIVED_DATA}`, + '', + ].join('\n'), + ); + }); + + it('omits workspace/project when neither provided', () => { + const result = formatToolPreflight({ + operation: 'Build', + scheme: 'MyApp', + configuration: 'Debug', + platform: 'macOS', + }); + + expect(result).toBe( + [ + '\u{1F528} Build', + '', + ' Scheme: MyApp', + ' Configuration: Debug', + ' Platform: macOS', + ` Derived Data: ${DISPLAY_DERIVED_DATA}`, + '', + ].join('\n'), + ); + }); + + it('formats test operation', () => { + const result = formatToolPreflight({ + operation: 'Test', + scheme: 'MyApp', + workspacePath: '/path/to/MyApp.xcworkspace', + configuration: 'Debug', + platform: 'iOS Simulator', + simulatorName: 'iPhone 17', + }); + + expect(result).toBe( + [ + '\u{1F9EA} Test', + '', + ' Scheme: MyApp', + ' Workspace: /path/to/MyApp.xcworkspace', + ' Configuration: Debug', + ' Platform: iOS Simulator', + ' Simulator: iPhone 17', + ` Derived Data: ${DISPLAY_DERIVED_DATA}`, + '', + ].join('\n'), + ); + }); + + it('shows relative path when under cwd', () => { + const cwd = process.cwd(); + const result = formatToolPreflight({ + operation: 'Build', + scheme: 'MyApp', + workspacePath: `${cwd}/MyApp.xcworkspace`, + configuration: 'Debug', + platform: 'macOS', + }); + + expect(result).toContain(' Workspace: MyApp.xcworkspace'); + expect(result).not.toContain(cwd); + }); + + it('shows absolute path when outside cwd', () => { + const result = formatToolPreflight({ + operation: 'Build', + scheme: 'MyApp', + projectPath: '/other/location/MyApp.xcodeproj', + configuration: 'Debug', + platform: 'macOS', + }); + + expect(result).toContain(' Project: /other/location/MyApp.xcodeproj'); + }); + + it('prefers simulator name over simulator ID when both provided', () => { + const result = formatToolPreflight({ + operation: 'Build', + scheme: 'MyApp', + configuration: 'Debug', + platform: 'iOS Simulator', + simulatorName: 'iPhone 17', + simulatorId: 'ABC-123', + }); + + expect(result).toContain('Simulator: iPhone 17'); + expect(result).not.toContain('ABC-123'); + }); +}); diff --git a/src/utils/__tests__/build-utils-suppress-warnings.test.ts b/src/utils/__tests__/build-utils-suppress-warnings.test.ts deleted file mode 100644 index 1eac6002..00000000 --- a/src/utils/__tests__/build-utils-suppress-warnings.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { executeXcodeBuildCommand } from '../build-utils.ts'; -import { XcodePlatform } from '../../types/common.ts'; -import { sessionStore } from '../session-store.ts'; -import { createMockExecutor } from '../../test-utils/mock-executors.ts'; - -describe('executeXcodeBuildCommand - suppressWarnings', () => { - beforeEach(() => { - sessionStore.clear(); - }); - - it('should include warnings when suppressWarnings is false', async () => { - sessionStore.setDefaults({ suppressWarnings: false }); - - const mockExecutor = createMockExecutor({ - success: true, - output: 'warning: Some warning\nerror: Some error', - error: '', - exitCode: 0, - }); - - const result = await executeXcodeBuildCommand( - { - projectPath: '/test/project.xcodeproj', - scheme: 'TestScheme', - configuration: 'Debug', - }, - { - platform: XcodePlatform.macOS, - logPrefix: 'Test', - }, - false, - 'build', - mockExecutor, - ); - - expect(result.content).toBeDefined(); - const textContent = result.content - ?.filter((c) => c.type === 'text') - .map((c) => (c as { text: string }).text) - .join('\n'); - expect(textContent).toContain('⚠️ Warning:'); - }); - - it('should suppress warnings when suppressWarnings is true', async () => { - sessionStore.setDefaults({ suppressWarnings: true }); - - const mockExecutor = createMockExecutor({ - success: true, - output: 'warning: Some warning\nerror: Some error', - error: '', - exitCode: 0, - }); - - const result = await executeXcodeBuildCommand( - { - projectPath: '/test/project.xcodeproj', - scheme: 'TestScheme', - configuration: 'Debug', - }, - { - platform: XcodePlatform.macOS, - logPrefix: 'Test', - }, - false, - 'build', - mockExecutor, - ); - - expect(result.content).toBeDefined(); - const textContent = result.content - ?.filter((c) => c.type === 'text') - .map((c) => (c as { text: string }).text) - .join('\n'); - expect(textContent).not.toContain('⚠️ Warning:'); - expect(textContent).toContain('❌ Error:'); - }); - - it('should not flag source code lines containing "error" or "warning" as diagnostics', async () => { - sessionStore.setDefaults({ suppressWarnings: false }); - - // Swift source lines echoed during compilation that contain "error"/"warning" as substrings - const buildOutput = [ - ' var authError: Error?', - ' private(set) var error: WalletError?', - ' private(set) var lastError: Error?', - ' var loadError: Error?', - ' private(set) var error: String?', - ' var warningCount: Int = 0', - ' let isWarning: Bool', - ' fatalError("unexpected state")', - ].join('\n'); - - const mockExecutor = createMockExecutor({ - success: true, - output: buildOutput, - error: '', - exitCode: 0, - }); - - const result = await executeXcodeBuildCommand( - { - projectPath: '/test/project.xcodeproj', - scheme: 'TestScheme', - configuration: 'Debug', - }, - { - platform: XcodePlatform.macOS, - logPrefix: 'Test', - }, - false, - 'build', - mockExecutor, - ); - - expect(result.content).toBeDefined(); - const textContent = result.content - ?.filter((c) => c.type === 'text') - .map((c) => (c as { text: string }).text) - .join('\n'); - expect(textContent).not.toContain('❌ Error:'); - expect(textContent).not.toContain('⚠️ Warning:'); - }); - - it('should match real xcodebuild diagnostic lines', async () => { - sessionStore.setDefaults({ suppressWarnings: false }); - - const buildOutput = [ - "/path/to/File.swift:42:10: error: cannot find 'foo' in scope", - "/path/to/File.swift:15:5: warning: unused variable 'bar'", - 'error: build failed', - 'warning: deprecated API usage', - 'ld: warning: directory not found for option', - 'clang: error: linker command failed', - 'xcode-select: error: tool xcodebuild requires Xcode', - 'fatal error: too many errors emitted', - "/path/to/header.h:1:9: fatal error: 'Header.h' file not found", - ].join('\n'); - - const mockExecutor = createMockExecutor({ - success: true, - output: buildOutput, - error: '', - exitCode: 0, - }); - - const result = await executeXcodeBuildCommand( - { - projectPath: '/test/project.xcodeproj', - scheme: 'TestScheme', - configuration: 'Debug', - }, - { - platform: XcodePlatform.macOS, - logPrefix: 'Test', - }, - false, - 'build', - mockExecutor, - ); - - expect(result.content).toBeDefined(); - const textContent = result.content - ?.filter((c) => c.type === 'text') - .map((c) => (c as { text: string }).text) - .join('\n'); - expect(textContent).toContain('❌ Error:'); - expect(textContent).toContain('⚠️ Warning:'); - }); -}); diff --git a/src/utils/__tests__/build-utils.test.ts b/src/utils/__tests__/build-utils.test.ts index a7fd73e9..4aa424fd 100644 --- a/src/utils/__tests__/build-utils.test.ts +++ b/src/utils/__tests__/build-utils.test.ts @@ -2,13 +2,30 @@ * Tests for build-utils Sentry classification logic */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import path from 'node:path'; import { createMockExecutor } from '../../test-utils/mock-executors.ts'; import { executeXcodeBuildCommand } from '../build-utils.ts'; import { XcodePlatform } from '../xcode.ts'; +import type { XcodebuildPipeline } from '../xcodebuild-pipeline.ts'; + +function createMockPipeline(): XcodebuildPipeline { + return { + onStdout: vi.fn(), + onStderr: vi.fn(), + emitEvent: vi.fn(), + finalize: vi.fn().mockReturnValue({ state: {}, mcpContent: [], events: [] }), + highestStageRank: vi.fn().mockReturnValue(0), + xcresultPath: null, + logPath: '/mock/log/path', + } as unknown as XcodebuildPipeline; +} describe('build-utils Sentry Classification', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + const mockPlatformOptions = { platform: XcodePlatform.macOS, logPrefix: 'Test Build', @@ -34,11 +51,12 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('❌ [stderr] xcodebuild: error: invalid option'); - expect(result.content[1].text).toContain('❌ Test Build build failed for scheme TestScheme'); + expect(result.content[0].text).toContain('Test Build build failed for scheme TestScheme'); }); }); @@ -56,11 +74,12 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('❌ [stderr] Scheme TestScheme was not found'); - expect(result.content[1].text).toContain('❌ Test Build build failed for scheme TestScheme'); + expect(result.content[0].text).toContain('Test Build build failed for scheme TestScheme'); }); it('should not trigger Sentry logging for exit code 66 (file not found)', async () => { @@ -76,10 +95,12 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('❌ [stderr] project.xcodeproj cannot be opened'); + expect(result.content[0].text).toContain('Test Build build failed for scheme TestScheme'); }); it('should not trigger Sentry logging for exit code 70 (destination error)', async () => { @@ -95,10 +116,12 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('❌ [stderr] Unable to find a destination matching'); + expect(result.content[0].text).toContain('Test Build build failed for scheme TestScheme'); }); it('should not trigger Sentry logging for exit code 1 (general build failure)', async () => { @@ -114,10 +137,12 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('❌ [stderr] Build failed with errors'); + expect(result.content[0].text).toContain('Test Build build failed for scheme TestScheme'); }); }); @@ -138,6 +163,8 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(result.isError).toBe(true); @@ -162,6 +189,8 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(result.isError).toBe(true); @@ -186,6 +215,8 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(result.isError).toBe(true); @@ -209,6 +240,8 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(result.isError).toBe(true); @@ -232,12 +265,12 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(result.isError).toBeFalsy(); - expect(result.content[0].text).toContain( - '✅ Test Build build succeeded for scheme TestScheme', - ); + expect(result.content[0].text).toContain('Test Build build succeeded for scheme TestScheme'); }); }); @@ -255,22 +288,70 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('❌ [stderr] Some error without exit code'); + expect(result.content[0].text).toContain('Test Build build failed for scheme TestScheme'); + }); + }); + + describe('Simulator Test Flags', () => { + it('should add simulator-specific flags when running simulator tests', async () => { + let capturedCommand: string[] | undefined; + const mockExecutor = createMockExecutor({ + success: true, + output: 'TEST SUCCEEDED', + exitCode: 0, + onExecute: (command) => { + capturedCommand = command; + }, + }); + + await executeXcodeBuildCommand( + { + scheme: 'TestScheme', + configuration: 'Debug', + projectPath: '/path/to/project.xcodeproj', + extraArgs: ['-only-testing:AppTests'], + }, + { + platform: XcodePlatform.iOSSimulator, + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 17 Pro', + logPrefix: 'Simulator Test', + }, + false, + 'test', + mockExecutor, + undefined, + createMockPipeline(), + ); + + expect(capturedCommand).toBeDefined(); + expect(capturedCommand).toContain('-destination'); + expect(capturedCommand).toContain('platform=iOS Simulator,id=SIM-UUID'); + expect(capturedCommand).toContain('COMPILER_INDEX_STORE_ENABLE=NO'); + expect(capturedCommand).toContain('ONLY_ACTIVE_ARCH=YES'); + expect(capturedCommand).toContain('-packageCachePath'); + expect(capturedCommand).toContain( + path.join(process.env.HOME ?? '', 'Library', 'Caches', 'org.swift.swiftpm'), + ); + expect(capturedCommand).toContain('-only-testing:AppTests'); + expect(capturedCommand?.at(-1)).toBe('test'); }); }); describe('Working Directory (cwd) Handling', () => { it('should pass project directory as cwd for workspace builds', async () => { - let capturedOptions: any; + let capturedOptions: Record | undefined; const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED', exitCode: 0, onExecute: (_command, _logPrefix, _useShell, opts) => { - capturedOptions = opts; + capturedOptions = opts as Record; }, }); @@ -284,20 +365,22 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(capturedOptions).toBeDefined(); - expect(capturedOptions.cwd).toBe('/path/to/project'); + expect(capturedOptions?.cwd).toBe('/path/to/project'); }); it('should pass project directory as cwd for project builds', async () => { - let capturedOptions: any; + let capturedOptions: Record | undefined; const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED', exitCode: 0, onExecute: (_command, _logPrefix, _useShell, opts) => { - capturedOptions = opts; + capturedOptions = opts as Record; }, }); @@ -311,20 +394,22 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(capturedOptions).toBeDefined(); - expect(capturedOptions.cwd).toBe('/path/to/project'); + expect(capturedOptions?.cwd).toBe('/path/to/project'); }); it('should merge cwd with existing execOpts', async () => { - let capturedOptions: any; + let capturedOptions: Record | undefined; const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED', exitCode: 0, onExecute: (_command, _logPrefix, _useShell, opts) => { - capturedOptions = opts; + capturedOptions = opts as Record; }, }); @@ -339,11 +424,12 @@ describe('build-utils Sentry Classification', () => { 'build', mockExecutor, { env: { CUSTOM_VAR: 'value' } }, + createMockPipeline(), ); expect(capturedOptions).toBeDefined(); - expect(capturedOptions.cwd).toBe('/path/to/project'); - expect(capturedOptions.env).toEqual({ CUSTOM_VAR: 'value' }); + expect(capturedOptions?.cwd).toBe('/path/to/project'); + expect(capturedOptions?.env).toEqual({ CUSTOM_VAR: 'value' }); }); it('should resolve relative project and derived data paths before execution', async () => { @@ -380,12 +466,16 @@ describe('build-utils Sentry Classification', () => { false, 'build', mockExecutor, + undefined, + createMockPipeline(), ); expect(capturedCommand).toBeDefined(); expect(capturedCommand).toContain(expectedProjectPath); expect(capturedCommand).toContain(expectedDerivedDataPath); - expect(capturedOptions).toEqual({ cwd: path.dirname(expectedProjectPath) }); + expect(capturedOptions).toEqual( + expect.objectContaining({ cwd: path.dirname(expectedProjectPath) }), + ); }); }); }); diff --git a/src/utils/__tests__/consolidate-content.test.ts b/src/utils/__tests__/consolidate-content.test.ts deleted file mode 100644 index 80815d8d..00000000 --- a/src/utils/__tests__/consolidate-content.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Tests for consolidateContentForClaudeCode - * - * Exercises the consolidation path by injecting a mock EnvironmentDetector - * that reports Claude Code as active, bypassing the production guard that - * disables consolidation during tests. - */ -import { describe, it, expect } from 'vitest'; -import { consolidateContentForClaudeCode } from '../validation.ts'; -import { createMockEnvironmentDetector } from '../../test-utils/mock-executors.ts'; -import type { ToolResponse } from '../../types/common.ts'; - -const claudeCodeDetector = createMockEnvironmentDetector({ isRunningUnderClaudeCode: true }); -const nonClaudeCodeDetector = createMockEnvironmentDetector({ isRunningUnderClaudeCode: false }); - -describe('consolidateContentForClaudeCode', () => { - describe('when Claude Code is detected', () => { - it('should consolidate multiple text blocks into one', () => { - const response: ToolResponse = { - content: [ - { type: 'text', text: 'Block 1' }, - { type: 'text', text: 'Block 2' }, - { type: 'text', text: 'Block 3' }, - ], - }; - - const result = consolidateContentForClaudeCode(response, claudeCodeDetector); - - expect(result.content).toHaveLength(1); - expect(result.content[0].type).toBe('text'); - expect((result.content[0] as { type: 'text'; text: string }).text).toBe( - 'Block 1\n---\nBlock 2\n---\nBlock 3', - ); - }); - - it('should return single-block responses unchanged', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Only block' }], - }; - - const result = consolidateContentForClaudeCode(response, claudeCodeDetector); - - expect(result).toBe(response); - }); - - it('should return empty content unchanged', () => { - const response: ToolResponse = { content: [] }; - - const result = consolidateContentForClaudeCode(response, claudeCodeDetector); - - expect(result).toBe(response); - }); - - it('should preserve isError flag', () => { - const response: ToolResponse = { - content: [ - { type: 'text', text: 'Error A' }, - { type: 'text', text: 'Error B' }, - ], - isError: true, - }; - - const result = consolidateContentForClaudeCode(response, claudeCodeDetector); - - expect(result.isError).toBe(true); - expect(result.content).toHaveLength(1); - }); - - it('should skip non-text content blocks and return original when no text found', () => { - const response: ToolResponse = { - content: [ - { type: 'image', data: 'base64data', mimeType: 'image/png' }, - { type: 'image', data: 'base64data2', mimeType: 'image/jpeg' }, - ], - }; - - const result = consolidateContentForClaudeCode(response, claudeCodeDetector); - - expect(result).toBe(response); - }); - - it('should consolidate only text blocks when mixed with image blocks', () => { - const response: ToolResponse = { - content: [ - { type: 'text', text: 'Text A' }, - { type: 'image', data: 'base64data', mimeType: 'image/png' }, - { type: 'text', text: 'Text B' }, - ], - }; - - const result = consolidateContentForClaudeCode(response, claudeCodeDetector); - - expect(result.content).toHaveLength(1); - expect(result.content[0].type).toBe('text'); - expect((result.content[0] as { type: 'text'; text: string }).text).toBe( - 'Text A\n---\nText B', - ); - }); - - it('should add separators only between text blocks, not before first', () => { - const response: ToolResponse = { - content: [ - { type: 'text', text: 'First' }, - { type: 'text', text: 'Second' }, - ], - }; - - const result = consolidateContentForClaudeCode(response, claudeCodeDetector); - - const text = (result.content[0] as { type: 'text'; text: string }).text; - expect(text).not.toMatch(/^---/); - expect(text).toBe('First\n---\nSecond'); - }); - - it('should preserve extra properties on the response', () => { - const response: ToolResponse = { - content: [ - { type: 'text', text: 'A' }, - { type: 'text', text: 'B' }, - ], - _meta: { foo: 'bar' }, - }; - - const result = consolidateContentForClaudeCode(response, claudeCodeDetector); - - expect(result._meta).toEqual({ foo: 'bar' }); - expect(result.content).toHaveLength(1); - }); - }); - - describe('when Claude Code is NOT detected', () => { - it('should return multi-block responses unchanged', () => { - const response: ToolResponse = { - content: [ - { type: 'text', text: 'Block 1' }, - { type: 'text', text: 'Block 2' }, - ], - }; - - const result = consolidateContentForClaudeCode(response, nonClaudeCodeDetector); - - expect(result).toBe(response); - expect(result.content).toHaveLength(2); - }); - - it('should return single-block responses unchanged', () => { - const response: ToolResponse = { - content: [{ type: 'text', text: 'Only block' }], - }; - - const result = consolidateContentForClaudeCode(response, nonClaudeCodeDetector); - - expect(result).toBe(response); - }); - }); - - describe('without explicit detector (default behavior)', () => { - it('should use default detector and not consolidate in test env', () => { - const response: ToolResponse = { - content: [ - { type: 'text', text: 'Block 1' }, - { type: 'text', text: 'Block 2' }, - ], - }; - - const result = consolidateContentForClaudeCode(response); - - expect(result).toBe(response); - expect(result.content).toHaveLength(2); - }); - }); -}); diff --git a/src/utils/__tests__/simulator-steps-pid.test.ts b/src/utils/__tests__/simulator-steps-pid.test.ts new file mode 100644 index 00000000..f62807d0 --- /dev/null +++ b/src/utils/__tests__/simulator-steps-pid.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { launchSimulatorAppWithLogging } from '../simulator-steps.ts'; + +function createMockChild(exitCode: number | null = null): ChildProcess { + const emitter = new EventEmitter(); + const child = emitter as unknown as ChildProcess; + Object.defineProperty(child, 'exitCode', { value: exitCode, writable: true }); + child.unref = vi.fn(); + Object.defineProperty(child, 'pid', { value: 99999, writable: true }); + return child; +} + +function createFileWritingSpawner(content: string, delayMs: number = 0) { + return (command: string, args: string[], options: SpawnOptions): ChildProcess => { + const child = createMockChild(null); + const stdio = options.stdio as [unknown, number, number]; + const fd = stdio[1]; + if (typeof fd === 'number') { + if (delayMs > 0) { + setTimeout(() => { + try { + fs.writeSync(fd, content); + } catch { + // fd may already be closed by the caller + } + }, delayMs); + } else { + fs.writeSync(fd, content); + } + } + return child; + }; +} + +describe('launchSimulatorAppWithLogging PID parsing', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('extracts PID from standard simctl colon format (bundleId: PID)', async () => { + const spawner = createFileWritingSpawner('com.example.app: 42567\n'); + const result = await launchSimulatorAppWithLogging( + 'test-sim-uuid', + 'com.example.app', + undefined, + { spawner }, + ); + + expect(result.success).toBe(true); + expect(result.processId).toBe(42567); + }); + + it('extracts PID from first line even when app output has bracketed numbers', async () => { + const spawner = createFileWritingSpawner( + 'com.example.app: 42567\n[404] Not Found\nHTTP [200] OK\n', + ); + const result = await launchSimulatorAppWithLogging( + 'test-sim-uuid', + 'com.example.app', + undefined, + { spawner }, + ); + + expect(result.success).toBe(true); + expect(result.processId).toBe(42567); + }); + + it('ignores non-PID first lines and returns undefined', async () => { + const spawner = createFileWritingSpawner('Loading resources...\n[404] Not Found\n'); + const result = await launchSimulatorAppWithLogging( + 'test-sim-uuid', + 'com.example.app', + undefined, + { spawner }, + ); + + expect(result.success).toBe(true); + // First line has no colon PID pattern, bracketed numbers are not matched + expect(result.processId).toBeUndefined(); + }); + + it('returns undefined when no PID is found within timeout', async () => { + // Write content with no PID pattern at all + const spawner = createFileWritingSpawner('Starting application...\nLoading resources...\n'); + + // Use a short timeout to not slow down tests + const result = await launchSimulatorAppWithLogging( + 'test-sim-uuid', + 'com.example.app', + undefined, + { spawner }, + ); + + expect(result.success).toBe(true); + expect(result.processId).toBeUndefined(); + }); +}); diff --git a/src/utils/__tests__/test-common.test.ts b/src/utils/__tests__/test-common.test.ts new file mode 100644 index 00000000..72f008fd --- /dev/null +++ b/src/utils/__tests__/test-common.test.ts @@ -0,0 +1,308 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createMockCommandResponse } from '../../test-utils/mock-executors.ts'; +import { + expectPendingBuildResponse, + runToolLogic, + type MockToolHandlerResult, +} from '../../test-utils/test-helpers.ts'; +import { handleTestLogic, resolveTestProgressEnabled } from '../test-common.ts'; +import { XcodePlatform } from '../xcode.ts'; + +function expectPendingTestResponse(result: MockToolHandlerResult, isError: boolean): void { + expect(result.isError()).toBe(isError); + expectPendingBuildResponse(result); +} + +function finalizeAndGetText(result: MockToolHandlerResult): string { + return result.text(); +} + +describe('resolveTestProgressEnabled', () => { + const originalRuntime = process.env.XCODEBUILDMCP_RUNTIME; + + afterEach(() => { + vi.restoreAllMocks(); + + if (originalRuntime === undefined) { + delete process.env.XCODEBUILDMCP_RUNTIME; + } else { + process.env.XCODEBUILDMCP_RUNTIME = originalRuntime; + } + }); + + it('defaults to true in MCP runtime when progress is not provided', () => { + process.env.XCODEBUILDMCP_RUNTIME = 'mcp'; + expect(resolveTestProgressEnabled(undefined)).toBe(true); + }); + + it('defaults to false in CLI runtime when progress is not provided', () => { + process.env.XCODEBUILDMCP_RUNTIME = 'cli'; + expect(resolveTestProgressEnabled(undefined)).toBe(false); + }); + + it('defaults to false when runtime is unknown', () => { + process.env.XCODEBUILDMCP_RUNTIME = 'unknown'; + expect(resolveTestProgressEnabled(undefined)).toBe(false); + }); + + it('honors explicit true override regardless of runtime', () => { + process.env.XCODEBUILDMCP_RUNTIME = 'cli'; + expect(resolveTestProgressEnabled(true)).toBe(true); + }); + + it('honors explicit false override regardless of runtime', () => { + process.env.XCODEBUILDMCP_RUNTIME = 'mcp'; + expect(resolveTestProgressEnabled(false)).toBe(false); + }); +}); + +describe('handleTestLogic (pipeline)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns a pending xcodebuild response for a failing macOS test run', async () => { + const executor = async ( + _command: string[], + _description?: string, + _useShell?: boolean, + _opts?: { + cwd?: string; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; + }, + ) => { + _opts?.onStdout?.('Resolve Package Graph\n'); + _opts?.onStdout?.('CompileSwift normal arm64 /tmp/App.swift\n'); + _opts?.onStdout?.('Testing started\n'); + _opts?.onStderr?.( + '/tmp/Test.swift:52: error: -[AppTests testFailure] : XCTAssertEqual failed: ("0") is not equal to ("1")\n', + ); + _opts?.onStdout?.("Test Case '-[AppTests testFailure]' failed (0.008 seconds)\n"); + _opts?.onStdout?.( + 'Executed 1 tests, with 1 failures (0 unexpected) in 0.123 (0.124) seconds\n', + ); + return createMockCommandResponse({ + success: false, + output: '', + error: '', + }); + }; + + const { result } = await runToolLogic(() => + handleTestLogic( + { + projectPath: '/tmp/App.xcodeproj', + scheme: 'App', + configuration: 'Debug', + platform: XcodePlatform.macOS, + progress: true, + }, + executor, + { + preflight: { + scheme: 'App', + configuration: 'Debug', + destinationName: 'iPhone 17 Pro', + projectPath: '/tmp/App.xcodeproj', + selectors: { onlyTesting: [], skipTesting: [] }, + targets: [], + warnings: [], + totalTests: 1, + completeness: 'complete', + }, + toolName: 'test_macos', + }, + ), + ); + + expectPendingTestResponse(result, true); + + const renderedText = finalizeAndGetText(result); + + expect(renderedText).toContain('Resolving packages'); + expect(renderedText).toContain('Compiling'); + expect(renderedText).toContain('Running tests'); + expect(renderedText).toContain('AppTests'); + expect(renderedText).toContain('testFailure:'); + expect(renderedText).toContain('XCTAssertEqual failed'); + expect(renderedText).toContain('1 test failed'); + + expect(renderedText).not.toContain('[stderr]'); + }); + + it('uses build-for-testing and test-without-building with exact discovered test selectors for simulator preflight runs', async () => { + const commands: string[][] = []; + const executor = async ( + command: string[], + _description?: string, + _useShell?: boolean, + _opts?: { + cwd?: string; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; + }, + ) => { + commands.push(command); + + if (command.includes('build-for-testing')) { + _opts?.onStdout?.('Resolve Package Graph\n'); + _opts?.onStdout?.('CompileSwift normal arm64 /tmp/App.swift\n'); + } else { + _opts?.onStdout?.('Testing started\n'); + _opts?.onStderr?.( + '/tmp/Test.swift:52: error: -[AppTests testFailure] : XCTAssertEqual failed: ("0") is not equal to ("1")\n', + ); + _opts?.onStdout?.("Test Case '-[AppTests testFailure]' failed (0.008 seconds)\n"); + _opts?.onStdout?.( + 'Executed 1 tests, with 1 failures (0 unexpected) in 0.123 (0.124) seconds\n', + ); + } + + return createMockCommandResponse({ + success: command.includes('build-for-testing'), + output: command.includes('build-for-testing') ? 'BUILD SUCCEEDED' : 'TEST FAILED', + error: '', + }); + }; + + const { result } = await runToolLogic(() => + handleTestLogic( + { + projectPath: '/tmp/App.xcodeproj', + scheme: 'App', + configuration: 'Debug', + platform: XcodePlatform.iOSSimulator, + simulatorId: 'SIM-UUID', + progress: true, + }, + executor, + { + preflight: { + scheme: 'App', + configuration: 'Debug', + destinationName: 'iPhone 17 Pro', + projectPath: '/tmp/App.xcodeproj', + selectors: { + onlyTesting: [{ raw: 'AppTests', target: 'AppTests' }], + skipTesting: [], + }, + targets: [ + { + name: 'AppTests', + warnings: [], + files: [ + { + path: '/tmp/AppTests.swift', + tests: [ + { + targetName: 'AppTests', + typeName: 'AppTests', + methodName: 'testFailure', + framework: 'xctest', + displayName: 'AppTests/AppTests/testFailure', + line: 1, + parameterized: false, + }, + ], + }, + ], + }, + ], + warnings: [], + totalTests: 1, + completeness: 'complete', + }, + toolName: 'test_sim', + }, + ), + ); + + expect(commands).toHaveLength(2); + expect(commands[0]?.[0]).toBe('xcodebuild'); + expect(commands[0]).toContain('build-for-testing'); + expect(commands[0]).toContain('COMPILER_INDEX_STORE_ENABLE=NO'); + expect(commands[0]).toContain('ONLY_ACTIVE_ARCH=YES'); + expect(commands[0]).toContain('-packageCachePath'); + expect(commands[0]).toContain('-only-testing:AppTests/AppTests/testFailure'); + expect(commands[0]).not.toContain('-resultBundlePath'); + expect(commands[0]).not.toContain('-only-testing:AppTests'); + expect(commands[1]?.[0]).toBe('xcodebuild'); + expect(commands[1]).toContain('test-without-building'); + expect(commands[1]).toContain('COMPILER_INDEX_STORE_ENABLE=NO'); + expect(commands[1]).toContain('ONLY_ACTIVE_ARCH=YES'); + expect(commands[1]).toContain('-packageCachePath'); + expect(commands[1]).not.toContain('-resultBundlePath'); + expect(commands[1]).toContain('-only-testing:AppTests/AppTests/testFailure'); + expect(commands[1]).not.toContain('-only-testing:AppTests'); + + expectPendingTestResponse(result, true); + + const renderedText = finalizeAndGetText(result); + expect(renderedText).toContain('Resolving packages'); + expect(renderedText).toContain('Compiling'); + expect(renderedText).toContain('Running tests'); + }); + + it('returns a pending xcodebuild response when compilation fails before tests start', async () => { + const executor = async ( + _command: string[], + _description?: string, + _useShell?: boolean, + _opts?: { + cwd?: string; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; + }, + ) => { + _opts?.onStdout?.('Resolve Package Graph\n'); + _opts?.onStdout?.('CompileSwift normal arm64 /tmp/App.swift\n'); + _opts?.onStdout?.( + "/tmp/App.swift:8:17: error: cannot convert value of type 'String' to specified type 'Int'\n", + ); + _opts?.onStdout?.('error: emit-module command failed with exit code 1\n'); + + return createMockCommandResponse({ + success: false, + output: '', + error: '', + }); + }; + + const { result } = await runToolLogic(() => + handleTestLogic( + { + projectPath: '/tmp/App.xcodeproj', + scheme: 'App', + configuration: 'Debug', + platform: XcodePlatform.macOS, + progress: true, + }, + executor, + { + preflight: { + scheme: 'App', + configuration: 'Debug', + destinationName: 'iPhone 17 Pro', + projectPath: '/tmp/App.xcodeproj', + selectors: { onlyTesting: [], skipTesting: [] }, + targets: [], + warnings: [], + totalTests: 1, + completeness: 'complete', + }, + toolName: 'test_macos', + }, + ), + ); + + expectPendingTestResponse(result, true); + + const renderedText = finalizeAndGetText(result); + expect(renderedText).toContain('Resolving packages'); + expect(renderedText).toContain('Compiling'); + expect(renderedText).toContain("cannot convert value of type 'String' to specified type 'Int'"); + expect(renderedText).toContain('emit-module command failed with exit code 1'); + expect(renderedText).toContain('Test failed.'); + }); +}); diff --git a/src/utils/__tests__/test-preflight.test.ts b/src/utils/__tests__/test-preflight.test.ts new file mode 100644 index 00000000..f335856a --- /dev/null +++ b/src/utils/__tests__/test-preflight.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; +import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; +import { formatTestPreflight, resolveTestPreflight } from '../test-preflight.ts'; + +describe('test-preflight', () => { + it('discovers XCTest and Swift Testing cases from scheme and test plan', async () => { + const files = new Map([ + [ + '/repo/App.xcworkspace/contents.xcworkspacedata', + ` + + +`, + ], + [ + '/repo/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme', + ` + + + + + + + + + + + +`, + ], + [ + '/repo/App/App.xctestplan', + JSON.stringify({ + testTargets: [ + { + target: { + name: 'FeatureTests', + containerPath: 'container:FeaturePackage', + }, + }, + ], + }), + ], + [ + '/repo/AppTests/AppTests.swift', + `import XCTest +final class AppTests: XCTestCase { + func testLaunch() {} +}`, + ], + [ + '/repo/FeaturePackage/Tests/FeatureTests/FeatureTests.swift', + `import Testing +@Suite struct FeatureTests { + @Test func testThing() {} +}`, + ], + ]); + + const knownDirs = new Set(['/repo/AppTests', '/repo/FeaturePackage/Tests/FeatureTests']); + + const fileSystem = createMockFileSystemExecutor({ + readFile: async (targetPath) => { + const content = files.get(targetPath); + if (content === undefined) { + throw Object.assign(new Error(`ENOENT: ${targetPath}`), { code: 'ENOENT' }); + } + return content; + }, + readdir: async (targetPath) => { + if (targetPath === '/repo/AppTests') { + return ['AppTests.swift']; + } + if (targetPath === '/repo/FeaturePackage/Tests/FeatureTests') { + return ['FeatureTests.swift']; + } + return []; + }, + stat: async (targetPath) => { + if (!files.has(targetPath) && !knownDirs.has(targetPath)) { + throw Object.assign(new Error(`ENOENT: ${targetPath}`), { code: 'ENOENT' }); + } + return { + isDirectory: () => knownDirs.has(targetPath), + mtimeMs: 0, + }; + }, + }); + + const result = await resolveTestPreflight( + { + workspacePath: '/repo/App.xcworkspace', + scheme: 'App', + configuration: 'Debug', + destinationName: 'iPhone 17 Pro', + }, + fileSystem, + ); + + expect(result?.totalTests).toBe(2); + expect(formatTestPreflight(result!)).toContain('Resolved to 2 test(s):'); + expect(formatTestPreflight(result!)).toContain('AppTests/AppTests/testLaunch'); + expect(formatTestPreflight(result!)).toContain('FeatureTests/FeatureTests/testThing'); + }); + + it('does not emit partial discovery warnings for intentionally targeted test runs', async () => { + const files = new Map([ + [ + '/repo/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme', + ` + + + + + + + + + + + +`, + ], + [ + '/repo/AppTests/AppTests.swift', + `import XCTest +final class AppTests: XCTestCase { + func testLaunch() {} +}`, + ], + [ + '/repo/FeaturePackage/Tests/FeatureTests/FeatureTests.swift', + `import Testing +@Suite struct FeatureTests { + @Test func testThing() {} +}`, + ], + ]); + + const knownDirs = new Set(['/repo/AppTests', '/repo/FeaturePackage/Tests/FeatureTests']); + + const fileSystem = createMockFileSystemExecutor({ + readFile: async (targetPath) => { + const content = files.get(targetPath); + if (content === undefined) { + throw Object.assign(new Error(`ENOENT: ${targetPath}`), { code: 'ENOENT' }); + } + return content; + }, + readdir: async (targetPath) => { + if (targetPath === '/repo/AppTests') { + return ['AppTests.swift']; + } + if (targetPath === '/repo/FeaturePackage/Tests/FeatureTests') { + return ['FeatureTests.swift']; + } + return []; + }, + stat: async (targetPath) => { + if (!files.has(targetPath) && !knownDirs.has(targetPath)) { + throw Object.assign(new Error(`ENOENT: ${targetPath}`), { code: 'ENOENT' }); + } + return { + isDirectory: () => knownDirs.has(targetPath), + mtimeMs: 0, + }; + }, + }); + + const result = await resolveTestPreflight( + { + projectPath: '/repo/App.xcodeproj', + scheme: 'App', + configuration: 'Debug', + destinationName: 'iPhone 17 Pro', + extraArgs: ['-only-testing:AppTests'], + }, + fileSystem, + ); + + expect(result?.totalTests).toBe(1); + expect(result?.completeness).toBe('complete'); + expect(result?.warnings).toEqual([]); + + const output = formatTestPreflight(result!); + expect(output).toContain('Resolved to 1 test(s):'); + expect(output).toContain('AppTests/AppTests/testLaunch'); + expect(output).not.toContain('Discovery completeness: partial'); + expect(output).not.toContain('Selectors filtered out all discovered tests'); + expect(output).not.toContain('FeatureTests/FeatureTests/testThing'); + }); +}); diff --git a/src/utils/__tests__/workflow-selection.test.ts b/src/utils/__tests__/workflow-selection.test.ts deleted file mode 100644 index 64baa7b1..00000000 --- a/src/utils/__tests__/workflow-selection.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { z } from 'zod'; -import { resolveSelectedWorkflows } from '../workflow-selection.ts'; -import type { WorkflowGroup } from '../../core/plugin-types.ts'; -import { - __resetConfigStoreForTests, - initConfigStore, - type RuntimeConfigOverrides, -} from '../config-store.ts'; -import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; - -const cwd = '/repo'; - -async function initConfigStoreForTest(overrides: RuntimeConfigOverrides): Promise { - __resetConfigStoreForTests(); - await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); -} - -function makeWorkflow(name: string): WorkflowGroup { - return { - directoryName: name, - workflow: { - name, - description: `${name} workflow`, - }, - tools: [ - { - name: `${name}-tool`, - description: `${name} tool`, - schema: { enabled: z.boolean().optional() }, - async handler() { - return { content: [] }; - }, - }, - ], - }; -} - -function makeWorkflowMap(names: string[]): Map { - const map = new Map(); - for (const name of names) { - map.set(name, makeWorkflow(name)); - } - return map; -} - -describe('resolveSelectedWorkflows', () => { - it('adds doctor when debug is enabled and selection list is provided', async () => { - await initConfigStoreForTest({ - debug: true, - experimentalWorkflowDiscovery: true, - }); - const workflows = makeWorkflowMap([ - 'session-management', - 'workflow-discovery', - 'doctor', - 'simulator', - ]); - - const result = resolveSelectedWorkflows(['simulator'], workflows); - - expect(result.selectedNames).toEqual([ - 'session-management', - 'workflow-discovery', - 'doctor', - 'simulator', - ]); - expect(result.selectedWorkflows.map((workflow) => workflow.directoryName)).toEqual([ - 'session-management', - 'workflow-discovery', - 'doctor', - 'simulator', - ]); - }); - - it('does not add doctor when debug is disabled', async () => { - await initConfigStoreForTest({ - debug: false, - experimentalWorkflowDiscovery: true, - }); - const workflows = makeWorkflowMap([ - 'session-management', - 'workflow-discovery', - 'doctor', - 'simulator', - ]); - - const result = resolveSelectedWorkflows(['simulator'], workflows); - - expect(result.selectedNames).toEqual(['session-management', 'workflow-discovery', 'simulator']); - expect(result.selectedWorkflows.map((workflow) => workflow.directoryName)).toEqual([ - 'session-management', - 'workflow-discovery', - 'simulator', - ]); - }); - - it('defaults to simulator workflow when no selection list is provided', async () => { - await initConfigStoreForTest({ - debug: true, - experimentalWorkflowDiscovery: true, - }); - const workflows = makeWorkflowMap([ - 'session-management', - 'workflow-discovery', - 'doctor', - 'simulator', - ]); - - const result = resolveSelectedWorkflows([], workflows); - - expect(result.selectedNames).toEqual([ - 'session-management', - 'workflow-discovery', - 'doctor', - 'simulator', - ]); - expect(result.selectedWorkflows.map((workflow) => workflow.directoryName)).toEqual([ - 'session-management', - 'workflow-discovery', - 'doctor', - 'simulator', - ]); - }); - - it('excludes workflow-discovery when experimental flag is disabled', async () => { - await initConfigStoreForTest({ - debug: false, - experimentalWorkflowDiscovery: false, - }); - const workflows = makeWorkflowMap(['session-management', 'workflow-discovery', 'simulator']); - - const result = resolveSelectedWorkflows([], workflows); - - expect(result.selectedNames).toEqual(['session-management', 'simulator']); - expect(result.selectedWorkflows.map((workflow) => workflow.directoryName)).toEqual([ - 'session-management', - 'simulator', - ]); - }); -}); diff --git a/src/utils/__tests__/xcodebuild-output.test.ts b/src/utils/__tests__/xcodebuild-output.test.ts new file mode 100644 index 00000000..b4ad2cb8 --- /dev/null +++ b/src/utils/__tests__/xcodebuild-output.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createMockToolHandlerContext } from '../../test-utils/test-helpers.ts'; +import { startBuildPipeline } from '../xcodebuild-pipeline.ts'; +import { finalizeInlineXcodebuild } from '../xcodebuild-output.ts'; + +async function runFinalizedPipeline( + logic: ( + started: ReturnType, + emit: ( + event: Parameters['ctx']['emit']>[0], + ) => void, + ) => void, +): Promise['result']> { + const { ctx, result, run } = createMockToolHandlerContext(); + await run(async () => { + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_macos', + params: { scheme: 'MyApp' }, + message: '🚀 Build & Run\n\n Scheme: MyApp', + }); + + logic(started, ctx.emit); + }); + return result; +} + +describe('xcodebuild-output', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv, XCODEBUILDMCP_RUNTIME: 'mcp' }; + delete process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; + }); + + it('suppresses fallback error content when structured diagnostics already exist', async () => { + const result = await runFinalizedPipeline((started, emit) => { + started.pipeline.emitEvent({ + type: 'compiler-error', + timestamp: '2026-03-20T12:00:00.500Z', + operation: 'BUILD', + message: 'unterminated string literal', + rawLine: '/tmp/MyApp.swift:10:1: error: unterminated string literal', + }); + + finalizeInlineXcodebuild({ + started, + emit, + succeeded: false, + durationMs: 100, + responseContent: [{ type: 'text', text: 'Legacy fallback error block' }], + errorFallbackPolicy: 'if-no-structured-diagnostics', + }); + }); + + const textContent = result.text(); + expect(textContent).toContain('Compiler Errors (1):'); + expect(textContent).toContain(' ✗ unterminated string literal'); + expect(textContent).toContain(' /tmp/MyApp.swift:10:1'); + expect(textContent).not.toContain('Legacy fallback error block'); + }); + + it('preserves fallback error content when no structured diagnostics exist', async () => { + const result = await runFinalizedPipeline((started, emit) => { + finalizeInlineXcodebuild({ + started, + emit, + succeeded: false, + durationMs: 100, + responseContent: [{ type: 'text', text: 'Legacy fallback error block' }], + errorFallbackPolicy: 'if-no-structured-diagnostics', + }); + }); + + expect(result.text()).toContain('Legacy fallback error block'); + }); + + it('renders build logs in a metadata tree after the summary when no tail detail tree exists', async () => { + const result = await runFinalizedPipeline((started, emit) => { + finalizeInlineXcodebuild({ + started, + emit, + succeeded: true, + durationMs: 100, + }); + }); + + expect(result.events.at(-2)?.type).toBe('summary'); + expect(result.events.at(-1)).toEqual( + expect.objectContaining({ + type: 'detail-tree', + items: [ + expect.objectContaining({ + label: 'Build Logs', + value: expect.stringContaining('build_run_macos_'), + }), + ], + }), + ); + expect(result.text()).toContain('✅ Build succeeded.'); + expect(result.text()).toContain('└ Build Logs:'); + }); + + it('surfaces parser debug logs with a warning notice before summary', async () => { + const result = await runFinalizedPipeline((started, emit) => { + started.pipeline.onStdout('UNRECOGNIZED LINE\n'); + + finalizeInlineXcodebuild({ + started, + emit, + succeeded: true, + durationMs: 100, + includeParserDebugFileRef: true, + }); + }); + + const textContent = result.text(); + expect(textContent).toContain('⚠️ Parsing issue detected - debug log:'); + expect(textContent).toContain('Parser Debug Log:'); + }); + + it('finalizes summary before execution-derived footer events', async () => { + const result = await runFinalizedPipeline((started, emit) => { + finalizeInlineXcodebuild({ + started, + emit, + succeeded: true, + durationMs: 100, + tailEvents: [ + { + type: 'status-line', + timestamp: '2026-03-20T12:00:01.000Z', + level: 'success', + message: 'Build & Run complete', + }, + { + type: 'detail-tree', + timestamp: '2026-03-20T12:00:01.000Z', + items: [{ label: 'App Path', value: '/tmp/build/MyApp.app' }], + }, + ], + }); + }); + + const lastThreeTypes = result.events.slice(-3).map((event) => event.type); + expect(lastThreeTypes).toEqual(['summary', 'status-line', 'detail-tree']); + expect(result.text()).toContain('✅ Build & Run complete'); + }); +}); diff --git a/src/utils/__tests__/xcodebuild-pipeline.test.ts b/src/utils/__tests__/xcodebuild-pipeline.test.ts new file mode 100644 index 00000000..a8270845 --- /dev/null +++ b/src/utils/__tests__/xcodebuild-pipeline.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { createXcodebuildPipeline } from '../xcodebuild-pipeline.ts'; +import { STAGE_RANK } from '../../types/pipeline-events.ts'; +import type { PipelineEvent } from '../../types/pipeline-events.ts'; +import { renderEvents } from '../../rendering/render.ts'; + +describe('xcodebuild-pipeline', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.XCODEBUILDMCP_RUNTIME = 'mcp'; + delete process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('produces MCP content from xcodebuild test output', () => { + const emittedEvents: PipelineEvent[] = []; + const pipeline = createXcodebuildPipeline({ + operation: 'TEST', + toolName: 'test_sim', + params: { scheme: 'MyApp' }, + emit: (event) => emittedEvents.push(event), + }); + + pipeline.emitEvent({ + type: 'header', + timestamp: '2025-01-01T00:00:00.000Z', + operation: 'Test', + params: [{ label: 'Scheme', value: 'MyApp' }], + }); + + pipeline.onStdout('Resolve Package Graph\n'); + pipeline.onStdout('CompileSwift normal arm64 /tmp/App.swift\n'); + pipeline.onStdout("Test Case '-[Suite testA]' passed (0.001 seconds)\n"); + pipeline.onStdout("Test Case '-[Suite testB]' failed (0.002 seconds)\n"); + + const result = pipeline.finalize(false, 2345); + + expect(result.state.finalStatus).toBe('FAILED'); + expect(result.state.completedTests).toBe(2); + expect(result.state.failedTests).toBe(1); + expect(result.state.milestones.map((m) => m.stage)).toContain('RESOLVING_PACKAGES'); + expect(result.state.milestones.map((m) => m.stage)).toContain('COMPILING'); + + // Rendered text should contain relevant content + const text = renderEvents(emittedEvents, 'text'); + expect(text).toContain('Test'); + expect(text).toContain('Resolving packages'); + + // Events array should contain all events + expect(emittedEvents.length).toBeGreaterThan(0); + const eventTypes = emittedEvents.map((e) => e.type); + expect(eventTypes).toContain('header'); + expect(eventTypes).toContain('build-stage'); + expect(eventTypes).toContain('test-progress'); + expect(eventTypes).toContain('summary'); + }); + + it('handles build output with warnings and errors', () => { + const emittedEvents: PipelineEvent[] = []; + const pipeline = createXcodebuildPipeline({ + operation: 'BUILD', + toolName: 'build_sim', + params: { scheme: 'MyApp' }, + emit: (event) => emittedEvents.push(event), + }); + + pipeline.onStdout('CompileSwift normal arm64 /tmp/App.swift\n'); + pipeline.onStdout('/tmp/App.swift:10:5: warning: variable unused\n'); + pipeline.onStdout("/tmp/App.swift:20:3: error: type 'Foo' has no member 'bar'\n"); + + const result = pipeline.finalize(false, 500); + + expect(result.state.warnings).toHaveLength(1); + expect(result.state.errors).toHaveLength(1); + expect(result.state.finalStatus).toBe('FAILED'); + }); + + it('supports multi-phase with minimumStage', () => { + // Phase 1: build-for-testing + const phase1Events: PipelineEvent[] = []; + const phase1 = createXcodebuildPipeline({ + operation: 'TEST', + toolName: 'test_sim', + params: {}, + emit: (event) => phase1Events.push(event), + }); + + phase1.onStdout('Resolve Package Graph\n'); + phase1.onStdout('CompileSwift normal arm64 /tmp/App.swift\n'); + + const phase1Rank = phase1.highestStageRank(); + expect(phase1Rank).toBe(STAGE_RANK.COMPILING); + + phase1.finalize(true, 1000); + + // Phase 2: test-without-building, skipping stages already seen + const stageEntries = Object.entries(STAGE_RANK) as Array<[string, number]>; + const minStage = stageEntries.find(([, rank]) => rank === phase1Rank)?.[0] as + | 'COMPILING' + | undefined; + + const phase2Events: PipelineEvent[] = []; + const phase2 = createXcodebuildPipeline({ + operation: 'TEST', + toolName: 'test_sim', + params: {}, + minimumStage: minStage, + emit: (event) => phase2Events.push(event), + }); + + // These should be suppressed + phase2.onStdout('Resolve Package Graph\n'); + phase2.onStdout('CompileSwift normal arm64 /tmp/App.swift\n'); + // This should pass through + phase2.onStdout("Test Case '-[Suite testA]' passed (0.001 seconds)\n"); + + const result = phase2.finalize(true, 2000); + + // Only RUN_TESTS milestone (auto-inserted from test-progress), not RESOLVING_PACKAGES or COMPILING + const milestoneStages = result.state.milestones.map((m) => m.stage); + expect(milestoneStages).not.toContain('RESOLVING_PACKAGES'); + expect(milestoneStages).not.toContain('COMPILING'); + expect(milestoneStages).toContain('RUN_TESTS'); + expect(result.state.completedTests).toBe(1); + }); + + it('emitEvent passes tool-originated events through the pipeline', () => { + const emittedEvents: PipelineEvent[] = []; + const pipeline = createXcodebuildPipeline({ + operation: 'TEST', + toolName: 'test_sim', + params: {}, + emit: (event) => emittedEvents.push(event), + }); + + pipeline.emitEvent({ + type: 'test-discovery', + timestamp: '2025-01-01T00:00:00.000Z', + operation: 'TEST', + total: 3, + tests: ['testA', 'testB', 'testC'], + truncated: false, + }); + + pipeline.finalize(true, 100); + + const discoveryEvents = emittedEvents.filter((e) => e.type === 'test-discovery'); + expect(discoveryEvents).toHaveLength(1); + }); + + it('produces JSONL output in CLI json mode', () => { + process.env.XCODEBUILDMCP_RUNTIME = 'cli'; + process.env.XCODEBUILDMCP_CLI_OUTPUT_FORMAT = 'json'; + + const emittedEvents: PipelineEvent[] = []; + const pipeline = createXcodebuildPipeline({ + operation: 'BUILD', + toolName: 'build_sim', + params: {}, + emit: (event) => emittedEvents.push(event), + }); + + pipeline.onStdout('CompileSwift normal arm64 /tmp/App.swift\n'); + pipeline.finalize(true, 100); + + expect(emittedEvents.length).toBeGreaterThan(0); + + // Each emitted event should be valid JSON-serializable with required fields + for (const event of emittedEvents) { + const parsed = JSON.parse(JSON.stringify(event)); + expect(parsed).toHaveProperty('type'); + expect(parsed).toHaveProperty('timestamp'); + } + }); +}); diff --git a/src/utils/app-path-resolver.ts b/src/utils/app-path-resolver.ts new file mode 100644 index 00000000..4a7a5504 --- /dev/null +++ b/src/utils/app-path-resolver.ts @@ -0,0 +1,99 @@ +import path from 'node:path'; +import type { XcodePlatform } from '../types/common.ts'; +import type { CommandExecutor } from './command.ts'; +import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; + +function resolvePathFromCwd(pathValue?: string): string | undefined { + if (!pathValue) { + return undefined; + } + + if (path.isAbsolute(pathValue)) { + return pathValue; + } + + return path.resolve(process.cwd(), pathValue); +} + +export function getBuildSettingsDestination(platform: XcodePlatform, deviceId?: string): string { + if (deviceId) { + return `platform=${platform},id=${deviceId}`; + } + return `generic/platform=${platform}`; +} + +export function extractAppPathFromBuildSettingsOutput(buildSettingsOutput: string): string { + const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); + const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); + + if (!builtProductsDirMatch || !fullProductNameMatch) { + throw new Error('Could not extract app path from build settings.'); + } + + return `${builtProductsDirMatch[1].trim()}/${fullProductNameMatch[1].trim()}`; +} + +export type ResolveAppPathFromBuildSettingsParams = { + projectPath?: string; + workspacePath?: string; + scheme: string; + configuration?: string; + platform: XcodePlatform; + deviceId?: string; + destination?: string; + derivedDataPath?: string; + extraArgs?: string[]; +}; + +/** + * Resolves the app bundle path from xcodebuild -showBuildSettings output. + * + * When `destination` is provided it is used directly; otherwise a generic + * destination is derived from `platform` and optional `deviceId`. + */ +export async function resolveAppPathFromBuildSettings( + params: ResolveAppPathFromBuildSettingsParams, + executor: CommandExecutor, +): Promise { + const command = ['xcodebuild', '-showBuildSettings']; + + const workspacePath = resolvePathFromCwd(params.workspacePath); + const projectPath = resolvePathFromCwd(params.projectPath); + const derivedDataPath = resolveEffectiveDerivedDataPath(params.derivedDataPath); + + let projectDir: string | undefined; + + if (projectPath) { + command.push('-project', projectPath); + projectDir = path.dirname(projectPath); + } else if (workspacePath) { + command.push('-workspace', workspacePath); + projectDir = path.dirname(workspacePath); + } + + command.push('-scheme', params.scheme); + command.push('-configuration', params.configuration ?? 'Debug'); + + const destination = + params.destination ?? getBuildSettingsDestination(params.platform, params.deviceId); + command.push('-destination', destination); + + command.push('-derivedDataPath', derivedDataPath); + + if (params.extraArgs && params.extraArgs.length > 0) { + command.push(...params.extraArgs); + } + + const result = await executor( + command, + 'Get App Path', + false, + projectDir ? { cwd: projectDir } : undefined, + ); + + if (!result.success) { + throw new Error(result.error ?? 'Unknown error'); + } + + return extractAppPathFromBuildSettingsOutput(result.output); +} diff --git a/src/utils/build-preflight.ts b/src/utils/build-preflight.ts new file mode 100644 index 00000000..2f3c2d4f --- /dev/null +++ b/src/utils/build-preflight.ts @@ -0,0 +1,119 @@ +import path from 'node:path'; +import os from 'node:os'; +import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; + +export interface ToolPreflightParams { + operation: + | 'Build' + | 'Build & Run' + | 'Clean' + | 'Test' + | 'List Schemes' + | 'Show Build Settings' + | 'Get App Path' + | 'Coverage Report' + | 'File Coverage'; + scheme?: string; + workspacePath?: string; + projectPath?: string; + configuration?: string; + platform?: string; + simulatorName?: string; + simulatorId?: string; + deviceId?: string; + deviceName?: string; + derivedDataPath?: string; + arch?: string; + xcresultPath?: string; + file?: string; + targetFilter?: string; +} + +export function displayPath(filePath: string): string { + const cwd = process.cwd(); + const relative = path.relative(cwd, filePath); + if (!relative.startsWith('..') && !path.isAbsolute(relative)) { + return relative; + } + + const home = os.homedir(); + if (filePath === home) { + return '~'; + } + if (filePath.startsWith(home + '/')) { + return '~/' + filePath.slice(home.length + 1); + } + + return filePath; +} + +const OPERATION_EMOJI: Record = { + Build: '\u{1F528}', + 'Build & Run': '\u{1F680}', + Clean: '\u{1F9F9}', + Test: '\u{1F9EA}', + 'List Schemes': '\u{1F50D}', + 'Show Build Settings': '\u{1F50D}', + 'Get App Path': '\u{1F50D}', + 'Coverage Report': '\u{1F4CA}', + 'File Coverage': '\u{1F4CA}', +}; + +export function formatToolPreflight(params: ToolPreflightParams): string { + const emoji = OPERATION_EMOJI[params.operation]; + const lines: string[] = [`${emoji} ${params.operation}`, '']; + + if (params.scheme) { + lines.push(` Scheme: ${params.scheme}`); + } + + if (params.workspacePath) { + lines.push(` Workspace: ${displayPath(params.workspacePath)}`); + } else if (params.projectPath) { + lines.push(` Project: ${displayPath(params.projectPath)}`); + } + + if (params.configuration) { + lines.push(` Configuration: ${params.configuration}`); + } + if (params.platform) { + lines.push(` Platform: ${params.platform}`); + } + + if (params.simulatorName) { + lines.push(` Simulator: ${params.simulatorName}`); + } else if (params.simulatorId) { + lines.push(` Simulator: ${params.simulatorId}`); + } + + if (params.deviceId) { + const deviceLabel = params.deviceName + ? `${params.deviceName} (${params.deviceId})` + : params.deviceId; + lines.push(` Device: ${deviceLabel}`); + } + + lines.push( + ` Derived Data: ${displayPath(resolveEffectiveDerivedDataPath(params.derivedDataPath))}`, + ); + + if (params.arch) { + lines.push(` Architecture: ${params.arch}`); + } + + if (params.xcresultPath) { + lines.push(` xcresult: ${displayPath(params.xcresultPath)}`); + } + + if (params.file) { + lines.push(` File: ${params.file}`); + } + + if (params.targetFilter) { + lines.push(` Target Filter: ${params.targetFilter}`); + } + + lines.push(''); + + return lines.join('\n'); +} diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index 41cb486a..fc87b244 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -1,27 +1,7 @@ -/** - * Build Utilities - Higher-level abstractions for Xcode build operations - * - * This utility module provides specialized functions for build-related operations - * across different platforms (macOS, iOS, watchOS, etc.). It serves as a higher-level - * abstraction layer on top of the core Xcode utilities. - * - * Responsibilities: - * - Providing a unified interface (executeXcodeBuild) for all build operations - * - Handling build-specific parameter formatting and validation - * - Standardizing response formatting for build results - * - Managing build-specific error handling and reporting - * - Supporting various build actions (build, clean, showBuildSettings, etc.) - * - Supporting xcodemake as an alternative build strategy for faster incremental builds - * - * This file depends on the lower-level utilities in xcode.ts for command execution - * while adding build-specific behavior, formatting, and error handling. - */ - import { log } from './logger.ts'; import { XcodePlatform, constructDestinationString } from './xcode.ts'; import type { CommandExecutor, CommandExecOptions } from './command.ts'; -import type { ToolResponse, SharedBuildParams, PlatformBuildOptions } from '../types/common.ts'; -import { createTextResponse } from './validation.ts'; +import type { SharedBuildParams, PlatformBuildOptions } from '../types/common.ts'; import { isXcodemakeEnabled, isXcodemakeAvailable, @@ -30,8 +10,16 @@ import { doesMakefileExist, doesMakeLogFileExist, } from './xcodemake.ts'; -import { sessionStore } from './session-store.ts'; import path from 'path'; +import os from 'node:os'; +import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; +import type { XcodebuildPipeline } from './xcodebuild-pipeline.ts'; +import { createNoticeEvent } from './xcodebuild-output.ts'; + +export interface BuildCommandResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} function resolvePathFromCwd(pathValue: string): string { if (path.isAbsolute(pathValue)) { @@ -40,15 +28,10 @@ function resolvePathFromCwd(pathValue: string): string { return path.resolve(process.cwd(), pathValue); } -/** - * Common function to execute an Xcode build command across platforms - * @param params Common build parameters - * @param platformOptions Platform-specific options - * @param preferXcodebuild Whether to prefer xcodebuild over xcodemake, useful for if xcodemake is failing - * @param buildAction The xcodebuild action to perform (e.g., 'build', 'clean', 'test') - * @param executor Optional command executor for dependency injection (used for testing) - * @returns Promise resolving to tool response - */ +function getDefaultSwiftPackageCachePath(): string { + return path.join(os.homedir(), 'Library', 'Caches', 'org.swift.swiftpm'); +} + export async function executeXcodeBuildCommand( params: SharedBuildParams, platformOptions: PlatformBuildOptions, @@ -56,28 +39,16 @@ export async function executeXcodeBuildCommand( buildAction: string = 'build', executor: CommandExecutor, execOpts?: CommandExecOptions, -): Promise { - // Collect warnings, errors, and stderr messages from the build output - const buildMessages: { type: 'text'; text: string }[] = []; - function grepWarningsAndErrors(text: string): { type: 'warning' | 'error'; content: string }[] { - // Require "error:"/"warning:" at line start (with optional tool prefix like "ld: ") - // or after a file:line:col location prefix, to avoid false positives from source - // code like "var authError: Error?" echoed during compilation. - return text - .split('\n') - .map((content) => { - if (/(?:^(?:[\w-]+:\s+)?|:\d+:\s+)warning:\s/i.test(content)) - return { type: 'warning', content }; - if (/(?:^(?:[\w-]+:\s+)?|:\d+:\s+)(?:fatal )?error:\s/i.test(content)) - return { type: 'error', content }; - return null; - }) - .filter(Boolean) as { type: 'warning' | 'error'; content: string }[]; + pipeline?: XcodebuildPipeline, +): Promise { + function addBuildMessage(message: string, level: 'info' | 'success' = 'info'): void { + pipeline?.emitEvent( + createNoticeEvent('BUILD', message.replace(/^[^\p{L}\p{N}]+/u, '').trim(), level), + ); } log('info', `Starting ${platformOptions.logPrefix} ${buildAction} for scheme ${params.scheme}`); - // Check if xcodemake is enabled and available const isXcodemakeEnabledFlag = isXcodemakeEnabled(); let xcodemakeAvailableFlag = false; @@ -89,34 +60,31 @@ export async function executeXcodeBuildCommand( 'info', 'xcodemake is enabled but preferXcodebuild is set to true. Falling back to xcodebuild.', ); - buildMessages.push({ - type: 'text', - text: '⚠️ incremental build support is enabled but preferXcodebuild is set to true. Falling back to xcodebuild.', - }); + addBuildMessage( + '⚠️ incremental build support is enabled but preferXcodebuild is set to true. Falling back to xcodebuild.', + ); } else if (!xcodemakeAvailableFlag) { - buildMessages.push({ - type: 'text', - text: '⚠️ xcodemake is enabled but not available. Falling back to xcodebuild.', - }); + addBuildMessage('⚠️ xcodemake is enabled but not available. Falling back to xcodebuild.'); log('info', 'xcodemake is enabled but not available. Falling back to xcodebuild.'); } else { log('info', 'xcodemake is enabled and available, using it for incremental builds.'); - buildMessages.push({ - type: 'text', - text: 'ℹ️ xcodemake is enabled and available, using it for incremental builds.', - }); + addBuildMessage('ℹ️ xcodemake is enabled and available, using it for incremental builds.'); } } + const useXcodemake = + isXcodemakeEnabledFlag && + xcodemakeAvailableFlag && + buildAction === 'build' && + !preferXcodebuild; + try { const command = ['xcodebuild']; const workspacePath = params.workspacePath ? resolvePathFromCwd(params.workspacePath) : undefined; const projectPath = params.projectPath ? resolvePathFromCwd(params.projectPath) : undefined; - const derivedDataPath = params.derivedDataPath - ? resolvePathFromCwd(params.derivedDataPath) - : undefined; + const derivedDataPath = resolveEffectiveDerivedDataPath(params.derivedDataPath); let projectDir = ''; if (workspacePath) { @@ -131,7 +99,6 @@ export async function executeXcodeBuildCommand( command.push('-configuration', params.configuration); command.push('-skipMacroValidation'); - // Construct destination string based on platform let destinationString: string; const isSimulatorPlatform = [ XcodePlatform.iOSSimulator, @@ -155,10 +122,8 @@ export async function executeXcodeBuildCommand( platformOptions.useLatestOS, ); } else { - return createTextResponse( - `For ${platformOptions.platform} platform, either simulatorId or simulatorName must be provided`, - true, - ); + const errorMsg = `For ${platformOptions.platform} platform, either simulatorId or simulatorName must be provided`; + return { content: [{ type: 'text', text: errorMsg }], isError: true }; } } else if (platformOptions.platform === XcodePlatform.macOS) { destinationString = constructDestinationString( @@ -168,76 +133,60 @@ export async function executeXcodeBuildCommand( false, platformOptions.arch, ); - } else if (platformOptions.platform === XcodePlatform.iOS) { - if (platformOptions.deviceId) { - destinationString = `platform=iOS,id=${platformOptions.deviceId}`; - } else { - destinationString = 'generic/platform=iOS'; - } - } else if (platformOptions.platform === XcodePlatform.watchOS) { - if (platformOptions.deviceId) { - destinationString = `platform=watchOS,id=${platformOptions.deviceId}`; - } else { - destinationString = 'generic/platform=watchOS'; - } - } else if (platformOptions.platform === XcodePlatform.tvOS) { - if (platformOptions.deviceId) { - destinationString = `platform=tvOS,id=${platformOptions.deviceId}`; - } else { - destinationString = 'generic/platform=tvOS'; - } - } else if (platformOptions.platform === XcodePlatform.visionOS) { + } else if ( + [ + XcodePlatform.iOS, + XcodePlatform.watchOS, + XcodePlatform.tvOS, + XcodePlatform.visionOS, + ].includes(platformOptions.platform) + ) { + const platformName = platformOptions.platform as string; if (platformOptions.deviceId) { - destinationString = `platform=visionOS,id=${platformOptions.deviceId}`; + destinationString = `platform=${platformName},id=${platformOptions.deviceId}`; } else { - destinationString = 'generic/platform=visionOS'; + destinationString = `generic/platform=${platformName}`; } } else { - return createTextResponse(`Unsupported platform: ${platformOptions.platform}`, true); + const errorMsg = `Unsupported platform: ${platformOptions.platform}`; + return { content: [{ type: 'text', text: errorMsg }], isError: true }; } command.push('-destination', destinationString); - if (derivedDataPath) { - command.push('-derivedDataPath', derivedDataPath); + if ( + ['test', 'build-for-testing', 'test-without-building'].includes(buildAction) && + isSimulatorPlatform + ) { + command.push('COMPILER_INDEX_STORE_ENABLE=NO'); + command.push('ONLY_ACTIVE_ARCH=YES'); + command.push( + '-packageCachePath', + platformOptions.packageCachePath ?? getDefaultSwiftPackageCachePath(), + ); } + command.push('-derivedDataPath', derivedDataPath); + if (params.extraArgs && params.extraArgs.length > 0) { command.push(...params.extraArgs); } command.push(buildAction); - // Execute the command using xcodemake or xcodebuild let result; - if ( - isXcodemakeEnabledFlag && - xcodemakeAvailableFlag && - buildAction === 'build' && - !preferXcodebuild - ) { - // Check if Makefile already exists + if (useXcodemake) { const makefileExists = doesMakefileExist(projectDir); log('debug', 'Makefile exists: ' + makefileExists); - // Check if Makefile log already exists const makeLogFileExists = doesMakeLogFileExist(projectDir, command); log('debug', 'Makefile log exists: ' + makeLogFileExists); if (makefileExists && makeLogFileExists) { - // Use make for incremental builds - buildMessages.push({ - type: 'text', - text: 'ℹ️ Using make for incremental build', - }); + addBuildMessage('ℹ️ Using make for incremental build'); result = await executeMakeCommand(projectDir, platformOptions.logPrefix); } else { - // Generate Makefile using xcodemake - buildMessages.push({ - type: 'text', - text: 'ℹ️ Generating Makefile with xcodemake (first build may take longer)', - }); - // Remove 'xcodebuild' from the command array before passing to executeXcodemakeCommand + addBuildMessage('ℹ️ Generating Makefile with xcodemake (first build may take longer)'); result = await executeXcodemakeCommand( projectDir, command.slice(1), @@ -245,33 +194,17 @@ export async function executeXcodeBuildCommand( ); } } else { - // Use standard xcodebuild - // Pass projectDir as cwd to ensure CocoaPods relative paths resolve correctly + const streamHandlers = pipeline + ? { + onStdout: (chunk: string) => pipeline.onStdout(chunk), + onStderr: (chunk: string) => pipeline.onStderr(chunk), + } + : {}; + result = await executor(command, platformOptions.logPrefix, false, { ...execOpts, cwd: projectDir, - }); - } - - // Grep warnings and errors from stdout (build output) - const warningOrErrorLines = grepWarningsAndErrors(result.output); - const suppressWarnings = sessionStore.get('suppressWarnings'); - warningOrErrorLines.forEach(({ type, content }) => { - if (type === 'warning' && suppressWarnings) { - return; - } - buildMessages.push({ - type: 'text', - text: type === 'warning' ? `⚠️ Warning: ${content}` : `❌ Error: ${content}`, - }); - }); - - // Include all stderr lines as errors - if (result.error) { - result.error.split('\n').forEach((content) => { - if (content.trim()) { - buildMessages.push({ type: 'text', text: `❌ [stderr] ${content}` }); - } + ...streamHandlers, }); } @@ -283,89 +216,31 @@ export async function executeXcodeBuildCommand( `${platformOptions.logPrefix} ${buildAction} failed: ${result.error}`, { sentry: isMcpError }, ); - const errorResponse = createTextResponse( - `❌ ${platformOptions.logPrefix} ${buildAction} failed for scheme ${params.scheme}.`, - true, - ); - if (buildMessages.length > 0 && errorResponse.content) { - errorResponse.content.unshift(...buildMessages); - } + const failureMsg = `${platformOptions.logPrefix} ${buildAction} failed for scheme ${params.scheme}.`; + const content: { type: 'text'; text: string }[] = [{ type: 'text', text: failureMsg }]; - // If using xcodemake and build failed but no compiling errors, suggest using xcodebuild - if ( - warningOrErrorLines.length == 0 && - isXcodemakeEnabledFlag && - xcodemakeAvailableFlag && - buildAction === 'build' && - !preferXcodebuild - ) { - errorResponse.content.push({ + if (useXcodemake) { + content.push({ type: 'text', - text: `💡 Incremental build using xcodemake failed, suggest using preferXcodebuild option to try build again using slower xcodebuild command.`, + text: 'Incremental build using xcodemake failed, suggest using preferXcodebuild option to try build again using slower xcodebuild command.', }); } - return errorResponse; - } - - log('info', `✅ ${platformOptions.logPrefix} ${buildAction} succeeded.`); - - // Create additional info based on platform and action - let additionalInfo = ''; - - // Add xcodemake info if relevant - if ( - isXcodemakeEnabledFlag && - xcodemakeAvailableFlag && - buildAction === 'build' && - !preferXcodebuild - ) { - additionalInfo += `xcodemake: Using faster incremental builds with xcodemake. -Future builds will use the generated Makefile for improved performance. - -`; + return { content, isError: true }; } - // Only show next steps for 'build' action - if (buildAction === 'build') { - if (platformOptions.platform === XcodePlatform.macOS) { - additionalInfo = `Next Steps: -1. Get app path: get_mac_app_path({ scheme: '${params.scheme}' }) -2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' }) -3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })`; - } else if (platformOptions.platform === XcodePlatform.iOS) { - additionalInfo = `Next Steps: -1. Get app path: get_device_app_path({ scheme: '${params.scheme}' }) -2. Get bundle ID: get_app_bundle_id({ appPath: 'PATH_FROM_STEP_1' }) -3. Launch: launch_app_device({ bundleId: 'BUNDLE_ID_FROM_STEP_2' })`; - } else if (isSimulatorPlatform) { - const simIdParam = platformOptions.simulatorId ? 'simulatorId' : 'simulatorName'; - const simIdValue = platformOptions.simulatorId ?? platformOptions.simulatorName; - - additionalInfo = `Next Steps: -1. Get app path: get_sim_app_path({ ${simIdParam}: '${simIdValue}', scheme: '${params.scheme}', platform: 'iOS Simulator' }) -2. Get bundle ID: get_app_bundle_id({ appPath: 'PATH_FROM_STEP_1' }) -3. Launch: launch_app_sim({ ${simIdParam}: '${simIdValue}', bundleId: 'BUNDLE_ID_FROM_STEP_2' }) - Or with logs: launch_app_logs_sim({ ${simIdParam}: '${simIdValue}', bundleId: 'BUNDLE_ID_FROM_STEP_2' })`; - } - } + log('info', `${platformOptions.logPrefix} ${buildAction} succeeded.`); - const successResponse: ToolResponse = { - content: [ - ...buildMessages, - { - type: 'text', - text: `✅ ${platformOptions.logPrefix} ${buildAction} succeeded for scheme ${params.scheme}.`, - }, - ], + const successText = `${platformOptions.logPrefix} ${buildAction} succeeded for scheme ${params.scheme}.`; + const successResponse: BuildCommandResult = { + content: [{ type: 'text', text: successText }], }; - // Only add additional info if we have any - if (additionalInfo) { + if (useXcodemake) { successResponse.content.push({ type: 'text', - text: additionalInfo, + text: `xcodemake: Using faster incremental builds with xcodemake.\nFuture builds will use the generated Makefile for improved performance.`, }); } @@ -382,9 +257,7 @@ Future builds will use the generated Makefile for improved performance. sentry: !isSpawnError, }); - return createTextResponse( - `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`, - true, - ); + const errorMsg = `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`; + return { content: [{ type: 'text', text: errorMsg }], isError: true }; } } diff --git a/src/utils/build/index.ts b/src/utils/build/index.ts index 61a6c70b..3edff2c3 100644 --- a/src/utils/build/index.ts +++ b/src/utils/build/index.ts @@ -1 +1,2 @@ -export { executeXcodeBuildCommand } from '../build-utils.ts'; \ No newline at end of file +export { executeXcodeBuildCommand } from '../build-utils.ts'; +export type { BuildCommandResult } from '../build-utils.ts'; \ No newline at end of file diff --git a/src/utils/capabilities.ts b/src/utils/capabilities.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/utils/derived-data-path.ts b/src/utils/derived-data-path.ts new file mode 100644 index 00000000..2b281a2c --- /dev/null +++ b/src/utils/derived-data-path.ts @@ -0,0 +1,12 @@ +import * as path from 'node:path'; +import { DERIVED_DATA_DIR } from './log-paths.ts'; + +export function resolveEffectiveDerivedDataPath(input?: string): string { + if (!input || input.trim().length === 0) { + return DERIVED_DATA_DIR; + } + if (path.isAbsolute(input)) { + return input; + } + return path.resolve(process.cwd(), input); +} diff --git a/src/utils/device-name-resolver.ts b/src/utils/device-name-resolver.ts new file mode 100644 index 00000000..876607a6 --- /dev/null +++ b/src/utils/device-name-resolver.ts @@ -0,0 +1,69 @@ +import { execSync } from 'node:child_process'; +import { readFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const CACHE_TTL_MS = 30_000; + +let cachedDevices: Map | null = null; +let cacheTimestamp = 0; + +interface DeviceCtlEntry { + identifier: string; + deviceProperties: { name: string }; + hardwareProperties?: { udid?: string }; +} + +function loadDeviceNames(): Map { + if (cachedDevices && Date.now() - cacheTimestamp < CACHE_TTL_MS) { + return cachedDevices; + } + + const map = new Map(); + const tmpFile = join(tmpdir(), `devicectl-list-${process.pid}.json`); + + try { + execSync(`xcrun devicectl list devices --json-output ${tmpFile}`, { + encoding: 'utf8', + timeout: 10_000, + stdio: 'pipe', + }); + + const data = JSON.parse(readFileSync(tmpFile, 'utf8')) as { + result?: { devices?: DeviceCtlEntry[] }; + }; + + for (const device of data.result?.devices ?? []) { + const name = device.deviceProperties.name; + map.set(device.identifier, name); + if (device.hardwareProperties?.udid) { + map.set(device.hardwareProperties.udid, name); + } + } + } catch { + // Device list unavailable -- return empty map, will fall back to UUID only + } finally { + try { + unlinkSync(tmpFile); + } catch { + // ignore + } + } + + cachedDevices = map; + cacheTimestamp = Date.now(); + return map; +} + +export function resolveDeviceName(deviceId: string): string | undefined { + const names = loadDeviceNames(); + return names.get(deviceId); +} + +export function formatDeviceId(deviceId: string): string { + const name = resolveDeviceName(deviceId); + if (name) { + return `${name} (${deviceId})`; + } + return deviceId; +} diff --git a/src/utils/device-steps.ts b/src/utils/device-steps.ts new file mode 100644 index 00000000..0da0506b --- /dev/null +++ b/src/utils/device-steps.ts @@ -0,0 +1,90 @@ +import { join } from 'node:path'; +import { log } from './logging/index.ts'; +import type { CommandExecutor } from './CommandExecutor.ts'; +import type { FileSystemExecutor } from './FileSystemExecutor.ts'; + +export interface StepResult { + success: boolean; + error?: string; +} + +export interface LaunchStepResult extends StepResult { + processId?: number; +} + +/** + * Install an app on a physical device. + */ +export async function installAppOnDevice( + deviceId: string, + appPath: string, + executor: CommandExecutor, +): Promise { + log('info', `Installing app on device ${deviceId}`); + const result = await executor( + ['xcrun', 'devicectl', 'device', 'install', 'app', '--device', deviceId, appPath], + 'Install app on device', + false, + ); + if (!result.success) { + return { success: false, error: result.error ?? 'Failed to install app' }; + } + return { success: true }; +} + +/** + * Launch an app on a physical device and return the process ID if available. + */ +export async function launchAppOnDevice( + deviceId: string, + bundleId: string, + executor: CommandExecutor, + fileSystem: FileSystemExecutor, + opts?: { env?: Record }, +): Promise { + log('info', `Launching app ${bundleId} on device ${deviceId}`); + const tempJsonPath = join(fileSystem.tmpdir(), `launch-${Date.now()}.json`); + + const command = [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + '--json-output', + tempJsonPath, + '--terminate-existing', + ]; + + if (opts?.env && Object.keys(opts.env).length > 0) { + command.push('--environment-variables', JSON.stringify(opts.env)); + } + + command.push(bundleId); + + const result = await executor(command, 'Launch app on device', false); + if (!result.success) { + await fileSystem.rm(tempJsonPath, { force: true }).catch(() => {}); + return { success: false, error: result.error ?? 'Failed to launch app' }; + } + + let processId: number | undefined; + try { + const jsonContent = await fileSystem.readFile(tempJsonPath, 'utf8'); + const parsedData = JSON.parse(jsonContent) as { + result?: { process?: { processIdentifier?: unknown } }; + }; + const pid = parsedData?.result?.process?.processIdentifier; + if (typeof pid === 'number') { + processId = pid; + } + } catch { + log('warn', 'Failed to parse launch JSON output for process ID'); + } finally { + await fileSystem.rm(tempJsonPath, { force: true }).catch(() => {}); + } + + return { success: true, processId }; +} diff --git a/src/utils/log-paths.ts b/src/utils/log-paths.ts new file mode 100644 index 00000000..9abb0f94 --- /dev/null +++ b/src/utils/log-paths.ts @@ -0,0 +1,7 @@ +import * as path from 'node:path'; +import * as os from 'node:os'; + +const APP_DIR = path.join(os.homedir(), 'Library', 'Developer', 'XcodeBuildMCP'); + +export const LOG_DIR = path.join(APP_DIR, 'logs'); +export const DERIVED_DATA_DIR = path.join(APP_DIR, 'DerivedData'); diff --git a/src/utils/macos-steps.ts b/src/utils/macos-steps.ts new file mode 100644 index 00000000..61409aa7 --- /dev/null +++ b/src/utils/macos-steps.ts @@ -0,0 +1,74 @@ +import path from 'node:path'; +import { log } from './logging/index.ts'; +import type { CommandExecutor } from './CommandExecutor.ts'; + +export interface MacLaunchResult { + success: boolean; + error?: string; + bundleId?: string; + processId?: number; +} + +/** + * Launch a macOS app and return bundle ID and process ID if available. + */ +export async function launchMacApp( + appPath: string, + executor: CommandExecutor, + opts?: { args?: string[] }, +): Promise { + log('info', `Launching macOS app: ${appPath}`); + const command = ['open', appPath]; + if (opts?.args?.length) { + command.push('--args', ...opts.args); + } + + const result = await executor(command, 'Launch macOS App', false); + if (!result.success) { + return { success: false, error: result.error ?? 'Failed to launch app' }; + } + + let bundleId: string | undefined; + try { + const plistResult = await executor( + ['/bin/sh', '-c', `defaults read "${appPath}/Contents/Info" CFBundleIdentifier`], + 'Extract Bundle ID', + false, + ); + if (plistResult.success && plistResult.output) { + bundleId = plistResult.output.trim(); + } + } catch { + // non-fatal + } + + const appName = path.basename(appPath, '.app'); + const processId = await resolveProcessId(appName, executor); + + return { success: true, bundleId, processId }; +} + +const MAC_PID_TIMEOUT_MS = 2000; +const MAC_PID_INTERVAL_MS = 100; + +async function resolveProcessId( + appName: string, + executor: CommandExecutor, +): Promise { + const start = Date.now(); + while (Date.now() - start < MAC_PID_TIMEOUT_MS) { + try { + const pgrepResult = await executor(['pgrep', '-x', appName], 'Get Process ID', false); + if (pgrepResult.success && pgrepResult.output) { + const pid = parseInt(pgrepResult.output.trim().split('\n')[0], 10); + if (!isNaN(pid)) { + return pid; + } + } + } catch { + // not visible yet + } + await new Promise((resolve) => setTimeout(resolve, MAC_PID_INTERVAL_MS)); + } + return undefined; +} diff --git a/src/utils/responses/index.ts b/src/utils/responses/index.ts new file mode 100644 index 00000000..c1c521a9 --- /dev/null +++ b/src/utils/responses/index.ts @@ -0,0 +1,15 @@ +export { createTextResponse } from '../validation.ts'; +export { + createErrorResponse, + DependencyError, + AxeError, + SystemError, + ValidationError, +} from '../errors.ts'; +export { + processToolResponse, + renderNextStep, + renderNextStepsSection, +} from './next-steps-renderer.ts'; + +export type { ToolResponse, NextStep, OutputStyle } from '../../types/common.ts'; diff --git a/src/utils/responses/next-steps-renderer.ts b/src/utils/responses/next-steps-renderer.ts index ebe30394..969e2d8a 100644 --- a/src/utils/responses/next-steps-renderer.ts +++ b/src/utils/responses/next-steps-renderer.ts @@ -1,5 +1,5 @@ import type { RuntimeKind } from '../../runtime/types.ts'; -import type { NextStep } from '../../types/common.ts'; +import type { NextStep, OutputStyle, ToolResponse } from '../../types/common.ts'; import { toKebabCase } from '../../runtime/naming.ts'; function resolveLabel(step: NextStep): string { @@ -90,3 +90,31 @@ export function renderNextStepsSection(steps: NextStep[], runtime: RuntimeKind): return `Next steps:\n${lines.join('\n')}`; } + +export function processToolResponse( + response: ToolResponse, + runtime: RuntimeKind, + style: OutputStyle = 'normal', +): ToolResponse { + const { nextSteps, ...rest } = response; + + if (!nextSteps || nextSteps.length === 0 || style === 'minimal') { + return { ...rest }; + } + + const nextStepsSection = renderNextStepsSection(nextSteps, runtime); + + const processedContent = response.content.map((item, index) => { + if (item.type === 'text' && index === response.content.length - 1) { + return { ...item, text: item.text + '\n\n' + nextStepsSection }; + } + return item; + }); + + const hasTextContent = response.content.some((item) => item.type === 'text'); + if (!hasTextContent && nextStepsSection) { + processedContent.push({ type: 'text', text: nextStepsSection.trim() }); + } + + return { ...rest, content: processedContent }; +} diff --git a/src/utils/simulator-steps.ts b/src/utils/simulator-steps.ts new file mode 100644 index 00000000..712b734b --- /dev/null +++ b/src/utils/simulator-steps.ts @@ -0,0 +1,289 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; +import { log } from './logging/index.ts'; +import type { CommandExecutor } from './CommandExecutor.ts'; +import { normalizeSimctlChildEnv } from './environment.ts'; +import { LOG_DIR } from './log-paths.ts'; + +export interface StepResult { + success: boolean; + error?: string; +} + +export interface LaunchStepResult extends StepResult { + processId?: number; +} + +export interface SimulatorInfo { + udid: string; + name: string; + state: string; +} + +/** + * Find a simulator by UUID and return its current state. + */ +export async function findSimulatorById( + simulatorId: string, + executor: CommandExecutor, +): Promise<{ simulator: SimulatorInfo | null; error?: string }> { + const listResult = await executor( + ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], + 'List Simulators', + ); + if (!listResult.success) { + return { simulator: null, error: listResult.error ?? 'Failed to list simulators' }; + } + + const simulatorsData = JSON.parse(listResult.output) as { + devices: Record; + }; + + for (const runtime in simulatorsData.devices) { + const devices = simulatorsData.devices[runtime]; + if (Array.isArray(devices)) { + for (const device of devices) { + if ( + typeof device === 'object' && + device !== null && + 'udid' in device && + 'name' in device && + 'state' in device && + typeof device.udid === 'string' && + typeof device.name === 'string' && + typeof device.state === 'string' && + device.udid === simulatorId + ) { + return { + simulator: { udid: device.udid, name: device.name, state: device.state }, + }; + } + } + } + } + + return { simulator: null }; +} + +/** + * Install an app on a simulator. + */ +export async function installAppOnSimulator( + simulatorId: string, + appPath: string, + executor: CommandExecutor, +): Promise { + log('info', `Installing app at path: ${appPath} to simulator: ${simulatorId}`); + const result = await executor( + ['xcrun', 'simctl', 'install', simulatorId, appPath], + 'Install App in Simulator', + false, + ); + if (!result.success) { + return { success: false, error: result.error ?? 'Failed to install app' }; + } + return { success: true }; +} + +/** + * Launch an app on a simulator and return the process ID if available. + */ +export async function launchSimulatorApp( + simulatorId: string, + bundleId: string, + executor: CommandExecutor, + opts?: { args?: string[]; env?: Record }, +): Promise { + log('info', `Launching app with bundle ID: ${bundleId} on simulator: ${simulatorId}`); + const command = ['xcrun', 'simctl', 'launch', simulatorId, bundleId]; + if (opts?.args?.length) { + command.push(...opts.args); + } + + const execOpts = opts?.env ? { env: normalizeSimctlChildEnv(opts.env) } : undefined; + const result = await executor(command, 'Launch App', false, execOpts); + if (!result.success) { + return { success: false, error: result.error ?? 'Failed to launch app' }; + } + + const pidMatch = result.output?.match(/:\s*(\d+)\s*$/); + const processId = pidMatch ? parseInt(pidMatch[1], 10) : undefined; + return { success: true, processId }; +} + +const PID_POLL_TIMEOUT_MS = 5000; +const PID_POLL_INTERVAL_MS = 100; + +export type ProcessSpawner = ( + command: string, + args: string[], + options: SpawnOptions, +) => ChildProcess; + +export interface LaunchWithLoggingResult { + success: boolean; + processId?: number; + logFilePath?: string; + osLogPath?: string; + error?: string; +} + +/** + * Launch an app on a simulator with implicit runtime logging. + * Uses `simctl launch --console-pty` to both launch the app and stream its + * stdout/stderr directly to a log file via OS-level fd inheritance. + * The process is fully detached — no Node.js streams or lifecycle management. + */ +export async function launchSimulatorAppWithLogging( + simulatorUuid: string, + bundleId: string, + options?: { + args?: string[]; + env?: Record; + }, + deps?: { + spawner?: ProcessSpawner; + }, +): Promise { + const spawner = deps?.spawner ?? spawn; + + const logsDir = LOG_DIR; + const ts = new Date().toISOString().replace(/:/g, '-').replace('.', '-').slice(0, -1) + 'Z'; + const logFileName = `${bundleId}_${ts}_pid${process.pid}.log`; + const logFilePath = path.join(logsDir, logFileName); + + let fd: number | undefined; + try { + fs.mkdirSync(logsDir, { recursive: true }); + fd = fs.openSync(logFilePath, 'w'); + + const args = [ + 'simctl', + 'launch', + '--console-pty', + '--terminate-running-process', + simulatorUuid, + bundleId, + ]; + if (options?.args?.length) { + args.push(...options.args); + } + + const spawnOpts: SpawnOptions = { + stdio: ['ignore', fd, fd], + detached: true, + }; + if (options?.env && Object.keys(options.env).length > 0) { + spawnOpts.env = { ...process.env, ...normalizeSimctlChildEnv(options.env) }; + } + + const child = spawner('xcrun', args, spawnOpts); + child.unref(); + fs.closeSync(fd); + fd = undefined; + + // Brief wait then check for immediate crash + await new Promise((resolve) => setTimeout(resolve, 300)); + if (child.exitCode !== null && child.exitCode !== 0) { + const logContent = readLogFileSafe(logFilePath); + return { + success: false, + logFilePath, + error: logContent || `Launch failed (exit code: ${child.exitCode})`, + }; + } + + const processId = await resolveAppPid(logFilePath); + + // Start OSLog stream as a separate detached process writing to its own file + const osLogPath = startOsLogStream(simulatorUuid, bundleId, logsDir, spawner); + + log('info', `Simulator app launched with logging: ${logFilePath}`); + return { success: true, processId, logFilePath, osLogPath }; + } catch (error) { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + const message = error instanceof Error ? error.message : String(error); + log('error', `Failed to launch simulator app with logging: ${message}`); + return { success: false, logFilePath, error: message }; + } +} + +async function resolveAppPid(logFilePath: string): Promise { + const start = Date.now(); + while (Date.now() - start < PID_POLL_TIMEOUT_MS) { + const content = readLogFileSafe(logFilePath); + if (content) { + const firstLine = content.split('\n').find((l) => l.trim().length > 0); + if (firstLine) { + const colonMatch = firstLine.match(/:\s*(\d+)\s*$/); + if (colonMatch) { + return parseInt(colonMatch[1], 10); + } + } + } + await new Promise((resolve) => setTimeout(resolve, PID_POLL_INTERVAL_MS)); + } + return undefined; +} + +function readLogFileSafe(filePath: string): string { + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return ''; + } +} + +function startOsLogStream( + simulatorUuid: string, + bundleId: string, + logsDir: string, + spawner: ProcessSpawner, +): string | undefined { + const ts = new Date().toISOString().replace(/:/g, '-').replace('.', '-').slice(0, -1) + 'Z'; + const osLogFilePath = path.join(logsDir, `${bundleId}_oslog_${ts}_pid${process.pid}.log`); + + let fd: number | undefined; + try { + fd = fs.openSync(osLogFilePath, 'w'); + + const child = spawner( + 'xcrun', + [ + 'simctl', + 'spawn', + simulatorUuid, + 'log', + 'stream', + '--level=debug', + '--predicate', + `subsystem == "${bundleId}"`, + ], + { + stdio: ['ignore', fd, fd], + detached: true, + }, + ); + child.unref(); + fs.closeSync(fd); + return osLogFilePath; + } catch (error) { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + const message = error instanceof Error ? error.message : String(error); + log('warn', `Failed to start OSLog stream: ${message}`); + return undefined; + } +} diff --git a/src/utils/simulator-test-execution.ts b/src/utils/simulator-test-execution.ts new file mode 100644 index 00000000..819d4b1e --- /dev/null +++ b/src/utils/simulator-test-execution.ts @@ -0,0 +1,62 @@ +import { collectResolvedTestSelectors, type TestPreflightResult } from './test-preflight.ts'; + +function parseTestSelectorArgs(extraArgs: string[] | undefined): { + remainingArgs: string[]; + selectorArgs: string[]; +} { + if (!extraArgs || extraArgs.length === 0) { + return { remainingArgs: [], selectorArgs: [] }; + } + + const remainingArgs: string[] = []; + const selectorArgs: string[] = []; + + for (let index = 0; index < extraArgs.length; index += 1) { + const argument = extraArgs[index]!; + + if (argument === '-only-testing' || argument === '-skip-testing') { + const value = extraArgs[index + 1]; + if (value) { + selectorArgs.push(argument, value); + index += 1; + } + continue; + } + + if (argument.startsWith('-only-testing:') || argument.startsWith('-skip-testing:')) { + selectorArgs.push(argument); + continue; + } + + remainingArgs.push(argument); + } + + return { remainingArgs, selectorArgs }; +} + +export function createSimulatorTwoPhaseExecutionPlan(params: { + extraArgs?: string[]; + preflight?: TestPreflightResult; + resultBundlePath?: string; +}): { + buildArgs: string[]; + testArgs: string[]; + usesExactSelectors: boolean; +} { + const { remainingArgs, selectorArgs } = parseTestSelectorArgs(params.extraArgs); + const resolvedSelectors = params.preflight ? collectResolvedTestSelectors(params.preflight) : []; + const exactSelectorArgs = resolvedSelectors.flatMap((selector) => [`-only-testing:${selector}`]); + const usesExactSelectors = exactSelectorArgs.length > 0; + + const selectedTestArgs = usesExactSelectors ? exactSelectorArgs : selectorArgs; + + return { + buildArgs: [...remainingArgs, ...selectedTestArgs], + testArgs: [ + ...remainingArgs, + ...selectedTestArgs, + ...(params.resultBundlePath ? ['-resultBundlePath', params.resultBundlePath] : []), + ], + usesExactSelectors, + }; +} diff --git a/src/utils/swift-test-discovery.ts b/src/utils/swift-test-discovery.ts new file mode 100644 index 00000000..6830795a --- /dev/null +++ b/src/utils/swift-test-discovery.ts @@ -0,0 +1,248 @@ +import type { FileSystemExecutor } from './FileSystemExecutor.ts'; + +export interface DiscoveredTestCase { + framework: 'xctest' | 'swift-testing'; + targetName: string; + typeName?: string; + methodName: string; + displayName: string; + line: number; + parameterized: boolean; +} + +export interface DiscoveredTestFile { + path: string; + tests: DiscoveredTestCase[]; +} + +interface SanitizerState { + inBlockComment: boolean; + inMultilineString: boolean; +} + +function sanitizeLine(input: string, state: SanitizerState): string { + let output = ''; + + for (let index = 0; index < input.length; index += 1) { + const current = input[index]; + const next = input[index + 1] ?? ''; + const triple = input.slice(index, index + 3); + + if (state.inBlockComment) { + if (current === '*' && next === '/') { + state.inBlockComment = false; + index += 1; + } + continue; + } + + if (state.inMultilineString) { + if (triple === '"""') { + state.inMultilineString = false; + index += 2; + } + continue; + } + + if (triple === '"""') { + state.inMultilineString = true; + index += 2; + continue; + } + + if (current === '/' && next === '*') { + state.inBlockComment = true; + index += 1; + continue; + } + + if (current === '/' && next === '/') { + break; + } + + if (current === '"') { + output += ' '; + index += 1; + while (index < input.length) { + if (input[index] === '\\') { + index += 2; + continue; + } + if (input[index] === '"') { + break; + } + index += 1; + } + continue; + } + + output += current; + } + + return output; +} + +function countBraces(line: string): number { + let delta = 0; + for (const character of line) { + if (character === '{') { + delta += 1; + } else if (character === '}') { + delta -= 1; + } + } + return delta; +} + +function collectXCTestTypes(lines: string[]): Set { + const xctestTypes = new Set(); + + for (const line of lines) { + const typeMatch = line.match( + /\b(?:final\s+)?(?:class|struct|actor)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^{]+)/, + ); + if (!typeMatch) { + continue; + } + + const [, typeName, inheritanceClause] = typeMatch; + if (inheritanceClause.includes('XCTestCase')) { + xctestTypes.add(typeName); + } + } + + return xctestTypes; +} + +function formatDisplayName( + targetName: string, + typeName: string | undefined, + methodName: string, +): string { + return `${targetName}/${typeName ?? 'Global'}/${methodName}`; +} + +function discoverTestsInFileContent( + targetName: string, + filePath: string, + content: string, +): DiscoveredTestFile | null { + const rawLines = content.split(/\r?\n/); + const sanitizerState: SanitizerState = { + inBlockComment: false, + inMultilineString: false, + }; + const sanitizedLines = rawLines.map((line) => sanitizeLine(line, sanitizerState)); + const xctestTypes = collectXCTestTypes(sanitizedLines); + const tests: DiscoveredTestCase[] = []; + const scopeStack: Array<{ typeName?: string; xctestContext: boolean; depth: number }> = []; + let braceDepth = 0; + let pendingAttributes: string[] = []; + + sanitizedLines.forEach((sanitizedLine, index) => { + const lineNumber = index + 1; + const line = sanitizedLine.trim(); + + while (scopeStack.length > 0 && braceDepth < scopeStack[scopeStack.length - 1].depth) { + scopeStack.pop(); + } + + if (line.startsWith('@')) { + pendingAttributes.push(line); + } + + const typeMatch = line.match( + /\b(?:final\s+)?(?:class|struct|actor)\s+([A-Za-z_][A-Za-z0-9_]*)\b(?:\s*:\s*([^{]+))?/, + ); + const extensionMatch = line.match(/\bextension\s+([A-Za-z_][A-Za-z0-9_]*)\b/); + + if (typeMatch && line.includes('{')) { + const typeName = typeMatch[1]; + const inheritanceClause = typeMatch[2] ?? ''; + scopeStack.push({ + typeName, + xctestContext: xctestTypes.has(typeName) || inheritanceClause.includes('XCTestCase'), + depth: braceDepth + Math.max(countBraces(line), 1), + }); + } else if (extensionMatch && line.includes('{')) { + const typeName = extensionMatch[1]; + scopeStack.push({ + typeName, + xctestContext: xctestTypes.has(typeName), + depth: braceDepth + Math.max(countBraces(line), 1), + }); + } + + const functionMatch = line.match(/\bfunc\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)/); + if (functionMatch) { + const methodName = functionMatch[1]; + const parameters = functionMatch[2].trim(); + const currentScope = scopeStack[scopeStack.length - 1]; + const hasTestAttribute = pendingAttributes.some((attribute) => attribute.startsWith('@Test')); + + if (currentScope?.xctestContext && methodName.startsWith('test') && parameters.length === 0) { + tests.push({ + framework: 'xctest', + targetName, + typeName: currentScope.typeName, + methodName, + displayName: formatDisplayName(targetName, currentScope.typeName, methodName), + line: lineNumber, + parameterized: false, + }); + } else if (hasTestAttribute) { + tests.push({ + framework: 'swift-testing', + targetName, + typeName: currentScope?.typeName, + methodName, + displayName: formatDisplayName(targetName, currentScope?.typeName, methodName), + line: lineNumber, + parameterized: pendingAttributes.some((attribute) => attribute.includes('arguments:')), + }); + } + + pendingAttributes = []; + } else if (line.length > 0 && !line.startsWith('@')) { + pendingAttributes = []; + } + + braceDepth += countBraces(line); + while (scopeStack.length > 0 && braceDepth < scopeStack[scopeStack.length - 1].depth) { + scopeStack.pop(); + } + }); + + return tests.length > 0 ? { path: filePath, tests } : null; +} + +export async function discoverSwiftTestsInFiles( + targetName: string, + filePaths: string[], + fileSystemExecutor: FileSystemExecutor, +): Promise { + const sortedPaths = [...filePaths].sort(); + const fileContents = await Promise.all( + sortedPaths.map(async (filePath) => { + try { + const content = await fileSystemExecutor.readFile(filePath, 'utf8'); + return { filePath, content }; + } catch { + return null; + } + }), + ); + + const discoveredFiles: DiscoveredTestFile[] = []; + for (const entry of fileContents) { + if (!entry) { + continue; + } + const result = discoverTestsInFileContent(targetName, entry.filePath, entry.content); + if (result) { + discoveredFiles.push(result); + } + } + + return discoveredFiles; +} diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts index 90411e6a..7d587ecf 100644 --- a/src/utils/test-common.ts +++ b/src/utils/test-common.ts @@ -2,153 +2,46 @@ * Common Test Utilities - Shared logic for test tools * * This module provides shared functionality for all test-related tools across different platforms. - * It includes common test execution logic, xcresult parsing, and utility functions used by - * platform-specific test tools. + * It includes common test execution logic and utility functions used by platform-specific test tools. * * Responsibilities: - * - Parsing xcresult bundles into human-readable format - * - Shared test execution logic with platform-specific handling + * - Shared test execution logic with platform-specific handling via the xcodebuild pipeline * - Common error handling and cleanup for test operations - * - Temporary directory management for xcresult files */ -import { join } from 'path'; import { log } from './logger.ts'; import type { XcodePlatform } from './xcode.ts'; import { executeXcodeBuildCommand } from './build/index.ts'; -import { createTextResponse } from './validation.ts'; +import { extractTestFailuresFromXcresult } from './xcresult-test-failures.ts'; +import { header, statusLine } from './tool-event-builders.ts'; import { normalizeTestRunnerEnv } from './environment.ts'; -import type { ToolResponse } from '../types/common.ts'; import type { CommandExecutor, CommandExecOptions } from './command.ts'; -import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from './command.ts'; -import type { FileSystemExecutor } from './FileSystemExecutor.ts'; -import { filterStderrContent, type XcresultSummary } from './test-result-content.ts'; - -/** - * Type definition for test summary structure from xcresulttool - */ -interface TestSummary { - title?: string; - result?: string; - totalTestCount?: number; - passedTests?: number; - failedTests?: number; - skippedTests?: number; - expectedFailures?: number; - environmentDescription?: string; - devicesAndConfigurations?: Array<{ - device?: { - deviceName?: string; - platform?: string; - osVersion?: string; - }; - }>; - testFailures?: Array<{ - testName?: string; - targetName?: string; - failureText?: string; - }>; - topInsights?: Array<{ - impact?: string; - text?: string; - }>; -} - -/** - * Parse xcresult bundle using xcrun xcresulttool - */ -export async function parseXcresultBundle( - resultBundlePath: string, - executor: CommandExecutor = getDefaultCommandExecutor(), -): Promise { - try { - const result = await executor( - ['xcrun', 'xcresulttool', 'get', 'test-results', 'summary', '--path', resultBundlePath], - 'Parse xcresult bundle', - true, - ); - - if (!result.success) { - throw new Error(result.error ?? 'Failed to parse xcresult bundle'); +import { getDefaultCommandExecutor } from './command.ts'; +import { + formatTestDiscovery, + collectResolvedTestSelectors, + type TestPreflightResult, +} from './test-preflight.ts'; +import { formatToolPreflight } from './build-preflight.ts'; +import { resolveDeviceName } from './device-name-resolver.ts'; +import { createSimulatorTwoPhaseExecutionPlan } from './simulator-test-execution.ts'; +import { startBuildPipeline } from './xcodebuild-pipeline.ts'; +import type { XcodebuildPipeline } from './xcodebuild-pipeline.ts'; +import { finalizeInlineXcodebuild } from './xcodebuild-output.ts'; +import { getHandlerContext } from './typed-tool-factory.ts'; + +function emitXcresultFailures(pipeline: XcodebuildPipeline): void { + const xcresultPath = pipeline.xcresultPath; + if (xcresultPath) { + const failures = extractTestFailuresFromXcresult(xcresultPath); + for (const event of failures) { + pipeline.emitEvent(event); } - - // Parse JSON response and format as human-readable - const summary = JSON.parse(result.output || '{}') as TestSummary; - return { - formatted: formatTestSummary(summary), - totalTestCount: typeof summary.totalTestCount === 'number' ? summary.totalTestCount : 0, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error parsing xcresult bundle: ${errorMessage}`); - throw error; } } -/** - * Format test summary JSON into human-readable text - */ -function formatTestSummary(summary: TestSummary): string { - const lines: string[] = []; - - lines.push(`Test Summary: ${summary.title ?? 'Unknown'}`); - lines.push(`Overall Result: ${summary.result ?? 'Unknown'}`); - lines.push(''); - - lines.push('Test Counts:'); - lines.push(` Total: ${summary.totalTestCount ?? 0}`); - lines.push(` Passed: ${summary.passedTests ?? 0}`); - lines.push(` Failed: ${summary.failedTests ?? 0}`); - lines.push(` Skipped: ${summary.skippedTests ?? 0}`); - lines.push(` Expected Failures: ${summary.expectedFailures ?? 0}`); - lines.push(''); - - if (summary.environmentDescription) { - lines.push(`Environment: ${summary.environmentDescription}`); - lines.push(''); - } - - if ( - summary.devicesAndConfigurations && - Array.isArray(summary.devicesAndConfigurations) && - summary.devicesAndConfigurations.length > 0 - ) { - const device = summary.devicesAndConfigurations[0].device; - if (device) { - lines.push( - `Device: ${device.deviceName ?? 'Unknown'} (${device.platform ?? 'Unknown'} ${device.osVersion ?? 'Unknown'})`, - ); - lines.push(''); - } - } - - if ( - summary.testFailures && - Array.isArray(summary.testFailures) && - summary.testFailures.length > 0 - ) { - lines.push('Test Failures:'); - summary.testFailures.forEach((failure, index: number) => { - lines.push( - ` ${index + 1}. ${failure.testName ?? 'Unknown Test'} (${failure.targetName ?? 'Unknown Target'})`, - ); - if (failure.failureText) { - lines.push(` ${failure.failureText}`); - } - }); - lines.push(''); - } - - if (summary.topInsights && Array.isArray(summary.topInsights) && summary.topInsights.length > 0) { - lines.push('Insights:'); - summary.topInsights.forEach((insight, index: number) => { - lines.push( - ` ${index + 1}. [${insight.impact ?? 'Unknown'}] ${insight.text ?? 'No description'}`, - ); - }); - } - - return lines.join('\n'); +export function resolveTestProgressEnabled(progress: boolean | undefined): boolean { + return progress ?? process.env.XCODEBUILDMCP_RUNTIME === 'mcp'; } /** @@ -164,112 +57,183 @@ export async function handleTestLogic( simulatorId?: string; deviceId?: string; useLatestOS?: boolean; + packageCachePath?: string; derivedDataPath?: string; extraArgs?: string[]; preferXcodebuild?: boolean; platform: XcodePlatform; testRunnerEnv?: Record; + progress?: boolean; }, executor: CommandExecutor = getDefaultCommandExecutor(), - fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), -): Promise { + options?: { + preflight?: TestPreflightResult; + toolName?: string; + }, +): Promise { log( 'info', `Starting test run for scheme ${params.scheme} on platform ${params.platform} (internal)`, ); + const ctx = getHandlerContext(); try { - // Create temporary directory for xcresult bundle - const tempDir = await fileSystemExecutor.mkdtemp( - join(fileSystemExecutor.tmpdir(), 'xcodebuild-test-'), - ); - const resultBundlePath = join(tempDir, 'TestResults.xcresult'); - - // Add resultBundlePath to extraArgs - const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath]; - - // Prepare execution options with TEST_RUNNER_ environment variables const execOpts: CommandExecOptions | undefined = params.testRunnerEnv ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } : undefined; - // Run the test command - const testResult = await executeXcodeBuildCommand( - { - ...params, - extraArgs, - }, - { - platform: params.platform, + const shouldUseTwoPhaseSimulatorExecution = + String(params.platform).includes('Simulator') && Boolean(options?.preflight); + + const resolvedToolName = options?.toolName ?? 'test_sim'; + + const deviceName = params.deviceId ? resolveDeviceName(params.deviceId) : undefined; + + const configText = formatToolPreflight({ + operation: 'Test', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration: params.configuration, + platform: String(params.platform), + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + deviceId: params.deviceId, + deviceName, + }); + + const discoveryText = options?.preflight ? formatTestDiscovery(options.preflight) : undefined; + + const preflightText = discoveryText ? `${configText}\n${discoveryText}` : configText; + + const started = startBuildPipeline({ + operation: 'TEST', + toolName: resolvedToolName, + params: { + scheme: params.scheme, + configuration: params.configuration, + platform: String(params.platform), simulatorName: params.simulatorName, simulatorId: params.simulatorId, deviceId: params.deviceId, - useLatestOS: params.useLatestOS, - logPrefix: 'Test Run', + preflight: preflightText, }, - params.preferXcodebuild, - 'test', - executor, - execOpts, - ); - - // Parse xcresult bundle if it exists, regardless of whether tests passed or failed - // Test failures are expected and should not prevent xcresult parsing - try { - log('info', `Attempting to parse xcresult bundle at: ${resultBundlePath}`); + message: preflightText, + }); - // Check if the file exists - try { - await fileSystemExecutor.stat(resultBundlePath); - log('info', `xcresult bundle exists at: ${resultBundlePath}`); - } catch { - log('warn', `xcresult bundle does not exist at: ${resultBundlePath}`); - throw new Error(`xcresult bundle not found at ${resultBundlePath}`); - } + const { pipeline } = started; + + if (options?.preflight && options.preflight.totalTests > 0) { + const discoveredTests = collectResolvedTestSelectors(options.preflight); + const maxTests = 20; + pipeline.emitEvent({ + type: 'test-discovery', + timestamp: new Date().toISOString(), + operation: 'TEST', + total: discoveredTests.length, + tests: discoveredTests.slice(0, maxTests), + truncated: discoveredTests.length > maxTests, + }); + } - const xcresult = await parseXcresultBundle(resultBundlePath, executor); - log('info', 'Successfully parsed xcresult bundle'); + const platformOptions = { + platform: params.platform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + deviceId: params.deviceId, + useLatestOS: params.useLatestOS, + packageCachePath: params.packageCachePath, + logPrefix: 'Test Run', + }; - // Clean up temporary directory - await fileSystemExecutor.rm(tempDir, { recursive: true, force: true }); + if (shouldUseTwoPhaseSimulatorExecution) { + const executionPlan = createSimulatorTwoPhaseExecutionPlan({ + extraArgs: params.extraArgs, + preflight: options?.preflight, + resultBundlePath: undefined, + }); + + const buildForTestingResult = await executeXcodeBuildCommand( + { ...params, extraArgs: executionPlan.buildArgs }, + platformOptions, + params.preferXcodebuild, + 'build-for-testing', + executor, + execOpts, + pipeline, + ); - // If no tests ran (for example build/setup failed), xcresult summary is not useful. - // Return raw output so the original diagnostics stay visible. - if (xcresult.totalTestCount === 0) { - log('info', 'xcresult reports 0 tests — returning raw build output'); - return testResult; + if (buildForTestingResult.isError) { + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + responseContent: buildForTestingResult.content, + errorFallbackPolicy: 'if-no-structured-diagnostics', + }); + return; } - // xcresult summary should be first. Drop stderr-only noise while preserving non-stderr lines. - const filteredContent = filterStderrContent(testResult.content); - const combinedResponse: ToolResponse = { - content: [ - { - type: 'text', - text: '\nTest Results Summary:\n' + xcresult.formatted, - }, - ...filteredContent, - ], - isError: testResult.isError, - }; - - return combinedResponse; - } catch (parseError) { - // If parsing fails, return original test result - log('warn', `Failed to parse xcresult bundle: ${parseError}`); + pipeline.emitEvent({ + type: 'build-stage', + timestamp: new Date().toISOString(), + operation: 'TEST', + stage: 'PREPARING_TESTS', + message: 'Preparing tests', + }); + + const testWithoutBuildingResult = await executeXcodeBuildCommand( + { ...params, extraArgs: executionPlan.testArgs }, + platformOptions, + params.preferXcodebuild, + 'test-without-building', + executor, + execOpts, + pipeline, + ); - // Clean up temporary directory even if parsing fails - try { - await fileSystemExecutor.rm(tempDir, { recursive: true, force: true }); - } catch (cleanupError) { - log('warn', `Failed to clean up temporary directory: ${cleanupError}`); - } + emitXcresultFailures(pipeline); - return testResult; + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: !testWithoutBuildingResult.isError, + durationMs: Date.now() - started.startedAt, + responseContent: testWithoutBuildingResult.content, + }); + return; } + + const singlePhaseResult = await executeXcodeBuildCommand( + params, + platformOptions, + params.preferXcodebuild, + 'test', + executor, + execOpts, + pipeline, + ); + + emitXcresultFailures(pipeline); + + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: !singlePhaseResult.isError, + durationMs: Date.now() - started.startedAt, + responseContent: singlePhaseResult.content, + }); + return; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during test run: ${errorMessage}`); - return createTextResponse(`Error during test run: ${errorMessage}`, true); + ctx.emit( + header('Test Run', [ + { label: 'Scheme', value: params.scheme }, + { label: 'Platform', value: String(params.platform) }, + ]), + ); + ctx.emit(statusLine('error', `Error during test run: ${errorMessage}`)); } } diff --git a/src/utils/test-preflight.ts b/src/utils/test-preflight.ts new file mode 100644 index 00000000..1cb14fcb --- /dev/null +++ b/src/utils/test-preflight.ts @@ -0,0 +1,528 @@ +import path from 'node:path'; +import type { FileSystemExecutor } from './FileSystemExecutor.ts'; +import { + discoverSwiftTestsInFiles, + type DiscoveredTestCase, + type DiscoveredTestFile, +} from './swift-test-discovery.ts'; + +export interface TestSelector { + raw: string; + target: string; + classOrSuite?: string; + method?: string; +} + +export interface ResolvedTestTarget { + name: string; + files: DiscoveredTestFile[]; + warnings: string[]; +} + +export interface TestPreflightResult { + scheme: string; + configuration: string; + workspacePath?: string; + projectPath?: string; + destinationName: string; + selectors: { + onlyTesting: TestSelector[]; + skipTesting: TestSelector[]; + }; + targets: ResolvedTestTarget[]; + warnings: string[]; + totalTests: number; + completeness: 'complete' | 'partial' | 'unresolved'; +} + +interface ReferencedTestTarget { + name: string; + containerPath?: string; +} + +function parseSelector(raw: string): TestSelector | null { + const parts = raw.split('/').filter(Boolean); + if (parts.length === 0) { + return null; + } + + return { + raw, + target: parts[0], + classOrSuite: parts[1], + method: parts[2], + }; +} + +function parseSelectors( + extraArgs: string[] | undefined, + flagName: '-only-testing' | '-skip-testing', +): TestSelector[] { + if (!extraArgs) { + return []; + } + + const selectors: TestSelector[] = []; + + for (let index = 0; index < extraArgs.length; index += 1) { + const argument = extraArgs[index]; + if (argument === flagName) { + const nextValue = extraArgs[index + 1]; + if (nextValue) { + const selector = parseSelector(nextValue); + if (selector) { + selectors.push(selector); + } + index += 1; + } + continue; + } + + if (argument.startsWith(`${flagName}:`)) { + const selector = parseSelector(argument.slice(flagName.length + 1)); + if (selector) { + selectors.push(selector); + } + } + } + + return selectors; +} + +function extractAttributeValue(tagBody: string, attributeName: string): string | undefined { + const match = tagBody.match(new RegExp(`${attributeName}\\s*=\\s*"([^"]+)"`)); + return match?.[1]; +} + +function resolveContainerReference(reference: string, baseDir: string): string { + if (reference.startsWith('container:')) { + return path.resolve(baseDir, reference.slice('container:'.length)); + } + if (reference.startsWith('group:')) { + return path.resolve(baseDir, reference.slice('group:'.length)); + } + if (reference.startsWith('absolute:')) { + return reference.slice('absolute:'.length); + } + return path.resolve(baseDir, reference); +} + +async function findSchemePath( + params: { workspacePath?: string; projectPath?: string; scheme: string }, + fileSystemExecutor: FileSystemExecutor, +): Promise { + const candidates: string[] = []; + + if (params.projectPath) { + candidates.push( + path.join(params.projectPath, 'xcshareddata', 'xcschemes', `${params.scheme}.xcscheme`), + ); + } + + if (params.workspacePath) { + candidates.push( + path.join(params.workspacePath, 'xcshareddata', 'xcschemes', `${params.scheme}.xcscheme`), + ); + + const workspaceDir = path.dirname(params.workspacePath); + const workspaceDataPath = path.join(params.workspacePath, 'contents.xcworkspacedata'); + try { + const workspaceData = await fileSystemExecutor.readFile(workspaceDataPath, 'utf8'); + const matches = [...workspaceData.matchAll(//g), + ]; + for (const match of testableMatches) { + const block = match[1]; + if (extractAttributeValue(block, 'skipped') === 'YES') { + continue; + } + + const blueprintName = extractAttributeValue(block, 'BlueprintName'); + if (!blueprintName) { + continue; + } + + targets.push({ + name: blueprintName, + containerPath: extractAttributeValue(block, 'ReferencedContainer'), + }); + } + + return targets; +} + +async function parseTestPlanTargets( + schemeContent: string, + baseDir: string, + fileSystemExecutor: FileSystemExecutor, +): Promise { + const targets: ReferencedTestTarget[] = []; + const matches = [...schemeContent.matchAll(/; + }; + + for (const testTarget of planJson.testTargets ?? []) { + const target = testTarget.target; + if (!target?.name) { + continue; + } + targets.push({ + name: target.name, + containerPath: target.containerPath, + }); + } + } + + return targets; +} + +async function listDirectoryEntries( + directoryPath: string, + fileSystemExecutor: FileSystemExecutor, +): Promise { + try { + const entries = await fileSystemExecutor.readdir(directoryPath); + return entries.flatMap((entry) => (typeof entry === 'string' ? [entry] : [])); + } catch { + return []; + } +} + +async function collectSwiftFiles( + directoryPath: string, + fileSystemExecutor: FileSystemExecutor, +): Promise { + const entries = await listDirectoryEntries(directoryPath, fileSystemExecutor); + if (entries.length === 0) { + return []; + } + + const entryPaths = entries.map((entry) => path.join(directoryPath, entry)); + const statResults = await Promise.all( + entryPaths.map(async (fullPath) => { + try { + const stats = await fileSystemExecutor.stat(fullPath); + return { fullPath, isDir: stats.isDirectory() }; + } catch { + return null; + } + }), + ); + + const files: string[] = []; + const subdirPromises: Array> = []; + + for (const result of statResults) { + if (!result) { + continue; + } + if (result.isDir) { + subdirPromises.push(collectSwiftFiles(result.fullPath, fileSystemExecutor)); + } else if (result.fullPath.endsWith('.swift')) { + files.push(result.fullPath); + } + } + + const nestedFiles = await Promise.all(subdirPromises); + return files.concat(...nestedFiles); +} + +async function resolveCandidateDirectories( + reference: ReferencedTestTarget, + params: { workspacePath?: string; projectPath?: string }, + fileSystemExecutor: FileSystemExecutor, +): Promise { + const roots = new Set(); + + if (reference.containerPath) { + const baseDir = path.dirname(params.workspacePath ?? params.projectPath ?? process.cwd()); + const resolvedContainer = resolveContainerReference(reference.containerPath, baseDir); + + if (resolvedContainer.endsWith('.xcodeproj')) { + const containerDir = path.dirname(resolvedContainer); + roots.add(path.join(containerDir, reference.name)); + roots.add(path.join(containerDir, 'Tests', reference.name)); + } else { + roots.add(path.join(resolvedContainer, 'Tests', reference.name)); + roots.add(path.join(resolvedContainer, reference.name)); + } + } + + if (params.workspacePath) { + const workspaceDir = path.dirname(params.workspacePath); + roots.add(path.join(workspaceDir, reference.name)); + roots.add(path.join(workspaceDir, 'Tests', reference.name)); + } + + if (params.projectPath) { + const projectDir = path.dirname(params.projectPath); + roots.add(path.join(projectDir, reference.name)); + roots.add(path.join(projectDir, 'Tests', reference.name)); + } + + const results = await Promise.all( + [...roots].map(async (candidate) => { + try { + await fileSystemExecutor.stat(candidate); + return candidate; + } catch { + return null; + } + }), + ); + return results.filter((candidate): candidate is string => candidate !== null); +} + +function selectorMatches(test: DiscoveredTestCase, selector: TestSelector): boolean { + if (selector.target !== test.targetName) { + return false; + } + if (selector.classOrSuite && selector.classOrSuite !== test.typeName) { + return false; + } + if (selector.method && selector.method !== test.methodName) { + return false; + } + return true; +} + +function applySelectors( + files: DiscoveredTestFile[], + selectors: { onlyTesting: TestSelector[]; skipTesting: TestSelector[] }, +): DiscoveredTestFile[] { + return files + .map((file) => { + let tests = file.tests; + if (selectors.onlyTesting.length > 0) { + tests = tests.filter((test) => + selectors.onlyTesting.some((selector) => selectorMatches(test, selector)), + ); + } + if (selectors.skipTesting.length > 0) { + tests = tests.filter( + (test) => !selectors.skipTesting.some((selector) => selectorMatches(test, selector)), + ); + } + return { + ...file, + tests, + }; + }) + .filter((file) => file.tests.length > 0); +} + +export async function resolveTestPreflight( + params: { + workspacePath?: string; + projectPath?: string; + scheme: string; + configuration: string; + extraArgs?: string[]; + destinationName: string; + }, + fileSystemExecutor: FileSystemExecutor, +): Promise { + const selectors = { + onlyTesting: parseSelectors(params.extraArgs, '-only-testing'), + skipTesting: parseSelectors(params.extraArgs, '-skip-testing'), + }; + + const warnings: string[] = []; + const schemePath = await findSchemePath(params, fileSystemExecutor); + if (!schemePath) { + warnings.push(`Could not find shared scheme file for ${params.scheme}.`); + return { + scheme: params.scheme, + configuration: params.configuration, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + destinationName: params.destinationName, + selectors, + targets: [], + warnings, + totalTests: 0, + completeness: 'unresolved', + }; + } + + const schemeContent = await fileSystemExecutor.readFile(schemePath, 'utf8'); + const baseDir = path.dirname(params.workspacePath ?? params.projectPath ?? schemePath); + const referencedTargets = new Map(); + + for (const target of parseSchemeTargets(schemeContent)) { + referencedTargets.set(target.name, target); + } + for (const target of await parseTestPlanTargets(schemeContent, baseDir, fileSystemExecutor)) { + referencedTargets.set(target.name, target); + } + + const targets: ResolvedTestTarget[] = []; + + for (const reference of referencedTargets.values()) { + const candidateDirectories = await resolveCandidateDirectories( + reference, + params, + fileSystemExecutor, + ); + const swiftFiles = ( + await Promise.all( + candidateDirectories.map((directoryPath) => + collectSwiftFiles(directoryPath, fileSystemExecutor), + ), + ) + ).flat(); + + if (swiftFiles.length === 0) { + const warning = `Could not resolve Swift source files for test target ${reference.name}.`; + warnings.push(warning); + targets.push({ + name: reference.name, + files: [], + warnings: [warning], + }); + continue; + } + + const discoveredFiles = await discoverSwiftTestsInFiles( + reference.name, + [...new Set(swiftFiles)], + fileSystemExecutor, + ); + const filteredFiles = applySelectors(discoveredFiles, selectors); + + if ( + filteredFiles.length === 0 && + selectors.onlyTesting.length + selectors.skipTesting.length > 0 + ) { + continue; + } + + if (discoveredFiles.length === 0) { + const warning = `Found source files for ${reference.name}, but could not statically discover concrete tests.`; + warnings.push(warning); + targets.push({ + name: reference.name, + files: [], + warnings: [warning], + }); + continue; + } + + targets.push({ + name: reference.name, + files: filteredFiles, + warnings: [], + }); + } + + const totalTests = targets.reduce( + (sum, target) => sum + target.files.reduce((fileSum, file) => fileSum + file.tests.length, 0), + 0, + ); + const unresolvedTargets = targets.filter((target) => target.files.length === 0).length; + let completeness: 'complete' | 'partial' | 'unresolved'; + if (totalTests === 0) { + completeness = 'unresolved'; + } else if (unresolvedTargets > 0 || warnings.length > 0) { + completeness = 'partial'; + } else { + completeness = 'complete'; + } + + return { + scheme: params.scheme, + configuration: params.configuration, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + destinationName: params.destinationName, + selectors, + targets, + warnings, + totalTests, + completeness, + }; +} + +export function collectResolvedTestSelectors(preflight: TestPreflightResult): string[] { + return preflight.targets + .flatMap((target) => target.files.flatMap((file) => file.tests.map((test) => test.displayName))) + .sort(); +} + +export function formatTestDiscovery( + preflight: TestPreflightResult, + options: { maxListedTests?: number } = {}, +): string { + const maxListedTests = options.maxListedTests ?? 5; + const discoveredTests = collectResolvedTestSelectors(preflight); + + const listedTests = discoveredTests.slice(0, maxListedTests); + const remainingCount = Math.max(discoveredTests.length - listedTests.length, 0); + const lines = [ + `Resolved to ${preflight.totalTests} test(s):`, + ...listedTests.map((test) => ` - ${test}`), + ]; + + if (remainingCount > 0) { + lines.push(` ... and ${remainingCount} more`); + } + + if (preflight.completeness !== 'complete') { + lines.push(`Discovery completeness: ${preflight.completeness}`); + } + + for (const warning of preflight.warnings) { + lines.push(`Warning: ${warning}`); + } + + return lines.join('\n'); +} + +/** + * @deprecated Use formatToolPreflight + formatTestDiscovery instead. + * Retained for backward compatibility with existing tests. + */ +export function formatTestPreflight( + preflight: TestPreflightResult, + options: { maxListedTests?: number } = {}, +): string { + return formatTestDiscovery(preflight, options); +} diff --git a/src/utils/test-result-content.ts b/src/utils/test-result-content.ts deleted file mode 100644 index 7cbde0c1..00000000 --- a/src/utils/test-result-content.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { ToolResponseContent } from '../types/common.ts'; - -export interface XcresultSummary { - formatted: string; - totalTestCount: number; -} - -export function filterStderrContent( - content: ToolResponseContent[] | undefined, -): ToolResponseContent[] { - if (!content) { - return []; - } - - return content.flatMap((item): ToolResponseContent[] => { - if (item.type !== 'text') { - return [item]; - } - - const filteredText = item.text - .split('\n') - .filter((line) => !line.includes('[stderr]')) - .join('\n') - .trim(); - - if (filteredText.length === 0) { - return []; - } - - return [{ ...item, text: filteredText }]; - }); -} diff --git a/src/utils/tool-error-handling.ts b/src/utils/tool-error-handling.ts new file mode 100644 index 00000000..d21b3fe9 --- /dev/null +++ b/src/utils/tool-error-handling.ts @@ -0,0 +1,60 @@ +import type { ToolHandlerContext } from '../rendering/types.ts'; +import type { HeaderEvent, PipelineEvent } from '../types/pipeline-events.ts'; +import { toErrorMessage } from './errors.ts'; +import { statusLine } from './tool-event-builders.ts'; +import { log } from './logging/index.ts'; + +export interface MapErrorContext { + error: unknown; + message: string; + headerEvent: HeaderEvent; + emit?: (event: PipelineEvent) => void; +} + +export interface WithErrorHandlingOptions { + header: HeaderEvent | (() => HeaderEvent); + errorMessage: string | ((errCtx: { message: string; error: unknown }) => string); + logMessage?: string | ((errCtx: { message: string; error: unknown }) => string); + mapError?: (errCtx: MapErrorContext) => void | undefined; +} + +export async function withErrorHandling( + ctx: ToolHandlerContext, + run: () => Promise, + options: WithErrorHandlingOptions, +): Promise { + try { + return await run(); + } catch (error) { + const message = toErrorMessage(error); + const headerEvent = typeof options.header === 'function' ? options.header() : options.header; + + if (options.mapError) { + let emitted = false; + const emit = (event: PipelineEvent) => { + ctx.emit(event); + emitted = true; + }; + options.mapError({ error, message, headerEvent, emit }); + if (emitted) { + return; + } + } + + if (options.logMessage !== undefined) { + const logMsg = + typeof options.logMessage === 'function' + ? options.logMessage({ message, error }) + : options.logMessage; + log('error', logMsg); + } + + const errorMsg = + typeof options.errorMessage === 'function' + ? options.errorMessage({ message, error }) + : options.errorMessage; + + ctx.emit(headerEvent); + ctx.emit(statusLine('error', errorMsg)); + } +} diff --git a/src/utils/validation/index.ts b/src/utils/validation/index.ts deleted file mode 100644 index 8b1303dd..00000000 --- a/src/utils/validation/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Focused validation facade. - * Prefer importing from 'utils/validation/index.js' instead of the legacy utils barrel. - */ -export * from '../validation.ts'; diff --git a/src/utils/workflow-selection.ts b/src/utils/workflow-selection.ts deleted file mode 100644 index dc1de3da..00000000 --- a/src/utils/workflow-selection.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { WorkflowGroup } from '../core/plugin-types.ts'; -import { getConfig } from './config-store.ts'; - -export const REQUIRED_WORKFLOW = 'session-management'; -export const WORKFLOW_DISCOVERY_WORKFLOW = 'workflow-discovery'; -export const DEBUG_WORKFLOW = 'doctor'; -export const DEFAULT_WORKFLOW = 'simulator'; - -type WorkflowName = string; - -function normalizeWorkflowNames(workflowNames: WorkflowName[]): WorkflowName[] { - return workflowNames.map((name) => name.trim().toLowerCase()).filter(Boolean); -} - -function isWorkflowGroup(value: WorkflowGroup | undefined): value is WorkflowGroup { - return Boolean(value); -} - -export function isDebugEnabled(): boolean { - return getConfig().debug; -} - -export function isWorkflowDiscoveryEnabled(): boolean { - return getConfig().experimentalWorkflowDiscovery; -} - -/** - * Resolve selected workflow names to only include workflows that - * match real workflows, ensuring the mandatory workflows are always included. - * - * @param workflowNames - The list of selected workflow names - * @returns The list of workflows to register. - */ -export function resolveSelectedWorkflowNames( - workflowNames: WorkflowName[] = [], - availableWorkflowNames: WorkflowName[] = [], -): { - selectedWorkflowNames: WorkflowName[]; - selectedNames: WorkflowName[]; -} { - const normalizedNames = normalizeWorkflowNames(workflowNames); - const baseAutoSelected = [REQUIRED_WORKFLOW]; - - if (isWorkflowDiscoveryEnabled()) { - baseAutoSelected.push(WORKFLOW_DISCOVERY_WORKFLOW); - } - - if (isDebugEnabled()) { - baseAutoSelected.push(DEBUG_WORKFLOW); - } - - // When no workflows specified, default to simulator workflow - const effectiveNames = normalizedNames.length > 0 ? normalizedNames : [DEFAULT_WORKFLOW]; - const selectedNames = [...new Set([...baseAutoSelected, ...effectiveNames])]; - - // Filter selected names to only include workflows that match real workflows. - const selectedWorkflowNames = selectedNames.filter((workflowName) => - availableWorkflowNames.includes(workflowName), - ); - - return { selectedWorkflowNames, selectedNames }; -} - -/** - * Resolve selected workflow groups to only include workflow groups that - * match real workflow groups, ensuring the mandatory workflow groups are always included. - * - * @param workflowNames - The list of selected workflow names - * @param workflowGroups - The map of workflow groups - * @returns The list of workflow groups to register. - */ -export function resolveSelectedWorkflows( - workflowNames: WorkflowName[] = [], - workflowGroupsParam?: Map, -): { - selectedWorkflows: WorkflowGroup[]; - selectedNames: WorkflowName[]; -} { - const resolvedWorkflowGroups = workflowGroupsParam ?? new Map(); - const availableWorkflowNames = [...resolvedWorkflowGroups.keys()]; - const selection = resolveSelectedWorkflowNames(workflowNames, availableWorkflowNames); - - const selectedWorkflows = selection.selectedWorkflowNames - .map((workflowName) => resolvedWorkflowGroups.get(workflowName)) - .filter(isWorkflowGroup); - - return { selectedWorkflows, selectedNames: selection.selectedNames }; -} - -export function collectToolNames(workflows: WorkflowGroup[]): string[] { - const toolNames = new Set(); - - for (const workflow of workflows) { - for (const tool of workflow.tools) { - if (tool?.name) { - toolNames.add(tool.name); - } - } - } - - return [...toolNames]; -} diff --git a/src/utils/xcodebuild-log-capture.ts b/src/utils/xcodebuild-log-capture.ts new file mode 100644 index 00000000..9e9d1297 --- /dev/null +++ b/src/utils/xcodebuild-log-capture.ts @@ -0,0 +1,87 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { LOG_DIR } from './log-paths.ts'; + +const FALLBACK_LOG_DIR = path.join(os.tmpdir(), 'XcodeBuildMCP', 'logs'); + +function resolveWritableLogDir(): string { + const candidates = [LOG_DIR, FALLBACK_LOG_DIR]; + + for (const candidate of candidates) { + try { + fs.mkdirSync(candidate, { recursive: true }); + fs.accessSync(candidate, fs.constants.W_OK); + return candidate; + } catch { + continue; + } + } + + throw new Error( + `Unable to create writable log directory in any candidate path: ${candidates.join(', ')}`, + ); +} + +function generateLogFileName(toolName: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `${toolName}_${timestamp}_pid${process.pid}.log`; +} + +export interface LogCapture { + write(chunk: string): void; + readonly path: string; + close(): void; +} + +export function createLogCapture(toolName: string): LogCapture { + const logDir = resolveWritableLogDir(); + const logPath = path.join(logDir, generateLogFileName(toolName)); + const fd = fs.openSync(logPath, 'w'); + + return { + write(chunk: string): void { + fs.writeSync(fd, chunk); + }, + get path(): string { + return logPath; + }, + close(): void { + try { + fs.closeSync(fd); + } catch { + // already closed + } + }, + }; +} + +export interface ParserDebugCapture { + addUnrecognizedLine(line: string): void; + readonly count: number; + flush(): string | null; +} + +export function createParserDebugCapture(toolName: string): ParserDebugCapture { + const lines: string[] = []; + + return { + addUnrecognizedLine(line: string): void { + lines.push(line); + }, + get count(): number { + return lines.length; + }, + flush(): string | null { + if (lines.length === 0) return null; + const logDir = resolveWritableLogDir(); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const debugPath = path.join(logDir, `${toolName}_parser-debug_${timestamp}.log`); + fs.writeFileSync( + debugPath, + `Unrecognized xcodebuild output lines (${lines.length}):\n\n${lines.join('\n')}\n`, + ); + return debugPath; + }, + }; +} diff --git a/src/utils/xcodebuild-output.ts b/src/utils/xcodebuild-output.ts new file mode 100644 index 00000000..8b2e454b --- /dev/null +++ b/src/utils/xcodebuild-output.ts @@ -0,0 +1,225 @@ +import type { + BuildRunResultNoticeData, + BuildRunStepNoticeData, + NoticeCode, + NoticeLevel, + PipelineEvent, + XcodebuildOperation, +} from '../types/pipeline-events.ts'; +import type { PipelineResult, StartedPipeline } from './xcodebuild-pipeline.ts'; +import { displayPath } from './build-preflight.ts'; +import { statusLine } from './tool-event-builders.ts'; + +export type ErrorFallbackPolicy = 'always' | 'if-no-structured-diagnostics'; + +interface FinalizeInlineXcodebuildOptions { + started: StartedPipeline; + succeeded: boolean; + durationMs: number; + responseContent?: Array<{ type: 'text'; text: string }>; + emit?: (event: PipelineEvent) => void; + emitSummary?: boolean; + tailEvents?: PipelineEvent[]; + errorFallbackPolicy?: ErrorFallbackPolicy; + includeBuildLogFileRef?: boolean; + includeParserDebugFileRef?: boolean; +} + +function createStructuredErrorEvent( + operation: XcodebuildOperation, + message: string, +): PipelineEvent { + return { + type: 'compiler-error', + timestamp: new Date().toISOString(), + operation, + message, + rawLine: message, + }; +} + +function formatBuildRunStepLabel(step: string): string { + switch (step) { + case 'resolve-app-path': + return 'Resolving app path'; + case 'resolve-simulator': + return 'Resolving simulator'; + case 'boot-simulator': + return 'Booting simulator'; + case 'install-app': + return 'Installing app'; + case 'extract-bundle-id': + return 'Extracting bundle ID'; + case 'launch-app': + return 'Launching app'; + default: + return 'Running step'; + } +} + +function extractTextContent( + content: Array<{ type: 'text'; text: string }> | undefined, +): Array<{ type: 'text'; text: string }> { + return (content ?? []).filter( + (item): item is { type: 'text'; text: string } => + item.type === 'text' && typeof item.text === 'string' && item.text.trim().length > 0, + ); +} + +export function createNoticeEvent( + operation: XcodebuildOperation, + message: string, + level: NoticeLevel = 'info', + options: { + code?: NoticeCode; + data?: + | Record + | BuildRunStepNoticeData + | BuildRunResultNoticeData; + } = {}, +): PipelineEvent { + if (options.code === 'build-run-step' && options.data && typeof options.data === 'object') { + const data = options.data as BuildRunStepNoticeData; + const stepLabel = formatBuildRunStepLabel(data.step); + return { + type: 'status-line', + timestamp: new Date().toISOString(), + level: data.status === 'succeeded' ? 'success' : 'info', + message: stepLabel, + }; + } + + const statusLevel = level === 'success' || level === 'warning' ? level : 'info'; + + return { + type: 'status-line', + timestamp: new Date().toISOString(), + level: statusLevel, + message, + }; +} + +export function createBuildRunResultEvents(data: BuildRunResultNoticeData): PipelineEvent[] { + const events: PipelineEvent[] = []; + + events.push({ + type: 'status-line', + timestamp: new Date().toISOString(), + level: 'success', + message: 'Build & Run complete', + }); + + const items: Array<{ label: string; value: string }> = [ + { label: 'App Path', value: displayPath(data.appPath) }, + ]; + + if (data.bundleId) { + items.push({ label: 'Bundle ID', value: data.bundleId }); + } + + if (data.appId) { + items.push({ label: 'App ID', value: data.appId }); + } + + if (data.processId !== undefined) { + items.push({ label: 'Process ID', value: String(data.processId) }); + } + + if (data.buildLogPath) { + items.push({ label: 'Build Logs', value: displayPath(data.buildLogPath) }); + } + + if (data.runtimeLogPath) { + items.push({ label: 'Runtime Logs', value: displayPath(data.runtimeLogPath) }); + } + + if (data.osLogPath) { + items.push({ label: 'OSLog', value: displayPath(data.osLogPath) }); + } + + if (data.launchState !== 'requested') { + items.push({ label: 'Launch', value: 'Running' }); + } + + events.push({ + type: 'detail-tree', + timestamp: new Date().toISOString(), + items, + }); + + return events; +} + +export function emitPipelineNotice( + started: StartedPipeline, + operation: XcodebuildOperation, + message: string, + level: NoticeLevel = 'info', + options: { + code?: NoticeCode; + data?: + | Record + | BuildRunStepNoticeData + | BuildRunResultNoticeData; + } = {}, +): void { + if (options.code === 'build-run-result' && options.data && typeof options.data === 'object') { + const resultEvents = createBuildRunResultEvents(options.data as BuildRunResultNoticeData); + for (const event of resultEvents) { + started.pipeline.emitEvent(event); + } + return; + } + started.pipeline.emitEvent(createNoticeEvent(operation, message, level, options)); +} + +export function emitPipelineError( + started: StartedPipeline, + operation: XcodebuildOperation, + message: string, +): void { + started.pipeline.emitEvent(createStructuredErrorEvent(operation, message)); +} + +export function isPendingXcodebuildResponse(response: { + _meta?: Record; +}): boolean { + const pending = response._meta?.pendingXcodebuild; + return ( + typeof pending === 'object' && + pending !== null && + (pending as { kind?: string }).kind === 'pending-xcodebuild' + ); +} + +export function finalizeInlineXcodebuild(options: FinalizeInlineXcodebuildOptions): PipelineResult { + const pipelineResult = options.started.pipeline.finalize(options.succeeded, options.durationMs, { + emitSummary: options.emitSummary, + tailEvents: options.tailEvents, + includeBuildLogFileRef: options.includeBuildLogFileRef, + includeParserDebugFileRef: options.includeParserDebugFileRef ?? false, + }); + + const fallbackContent = extractTextContent(options.responseContent); + const hasStructuredDiagnostics = + pipelineResult.state.errors.length > 0 || pipelineResult.state.testFailures.length > 0; + const errorFallbackPolicy = options.errorFallbackPolicy ?? 'if-no-structured-diagnostics'; + const shouldEmitFallback = + !options.succeeded && + fallbackContent.length > 0 && + (errorFallbackPolicy === 'always' || !hasStructuredDiagnostics); + + if (!shouldEmitFallback) { + return pipelineResult; + } + + const fallbackEvents = fallbackContent.map((item) => statusLine('error', item.text)); + for (const event of fallbackEvents) { + options.emit?.(event); + } + + return { + ...pipelineResult, + events: [...pipelineResult.events, ...fallbackEvents], + }; +} diff --git a/src/utils/xcodebuild-pipeline.ts b/src/utils/xcodebuild-pipeline.ts new file mode 100644 index 00000000..1a49a39f --- /dev/null +++ b/src/utils/xcodebuild-pipeline.ts @@ -0,0 +1,284 @@ +import type { + XcodebuildOperation, + XcodebuildStage, + PipelineEvent, +} from '../types/pipeline-events.ts'; +import { createXcodebuildEventParser } from './xcodebuild-event-parser.ts'; +import { createXcodebuildRunState } from './xcodebuild-run-state.ts'; +import type { XcodebuildRunState } from './xcodebuild-run-state.ts'; +import { displayPath } from './build-preflight.ts'; +import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; +import { formatDeviceId } from './device-name-resolver.ts'; +import { createLogCapture, createParserDebugCapture } from './xcodebuild-log-capture.ts'; +import { log as appLog } from './logging/index.ts'; +import { getHandlerContext, handlerContextStorage } from './typed-tool-factory.ts'; + +export interface PipelineOptions { + operation: XcodebuildOperation; + toolName: string; + params: Record; + minimumStage?: XcodebuildStage; + emit?: (event: PipelineEvent) => void; +} + +export interface PipelineResult { + state: XcodebuildRunState; + events: PipelineEvent[]; +} + +export interface PipelineFinalizeOptions { + emitSummary?: boolean; + tailEvents?: PipelineEvent[]; + includeBuildLogFileRef?: boolean; + includeParserDebugFileRef?: boolean; +} + +export interface XcodebuildPipeline { + onStdout(chunk: string): void; + onStderr(chunk: string): void; + emitEvent(event: PipelineEvent): void; + finalize( + succeeded: boolean, + durationMs?: number, + options?: PipelineFinalizeOptions, + ): PipelineResult; + highestStageRank(): number; + xcresultPath: string | null; + logPath: string; +} + +export interface StartedPipeline { + pipeline: XcodebuildPipeline; + startedAt: number; +} + +function buildLogDetailTreeEvent(logPath: string): PipelineEvent { + return { + type: 'detail-tree', + timestamp: new Date().toISOString(), + items: [{ label: 'Build Logs', value: logPath }], + }; +} + +function injectBuildLogIntoTailEvents( + tailEvents: PipelineEvent[], + logPath: string, +): PipelineEvent[] { + const hasBuildLogTree = tailEvents.some( + (event) => + event.type === 'detail-tree' && event.items.some((item) => item.label === 'Build Logs'), + ); + if (hasBuildLogTree) { + return tailEvents; + } + + const detailTreeIndex = tailEvents.findIndex((event) => event.type === 'detail-tree'); + if (detailTreeIndex !== -1) { + const detailTreeEvent = tailEvents[detailTreeIndex]; + if (detailTreeEvent.type !== 'detail-tree') { + return tailEvents; + } + + const updatedTailEvents = [...tailEvents]; + updatedTailEvents[detailTreeIndex] = { + ...detailTreeEvent, + items: [...detailTreeEvent.items, { label: 'Build Logs', value: logPath }], + }; + return updatedTailEvents; + } + + const nextStepsIndex = tailEvents.findIndex((event) => event.type === 'next-steps'); + if (nextStepsIndex === -1) { + return [...tailEvents, buildLogDetailTreeEvent(logPath)]; + } + + return [ + ...tailEvents.slice(0, nextStepsIndex), + buildLogDetailTreeEvent(logPath), + ...tailEvents.slice(nextStepsIndex), + ]; +} + +function buildHeaderParams( + params: Record, +): Array<{ label: string; value: string }> { + const result: Array<{ label: string; value: string }> = []; + const keyLabelMap: Record = { + scheme: 'Scheme', + workspacePath: 'Workspace', + projectPath: 'Project', + configuration: 'Configuration', + platform: 'Platform', + simulatorName: 'Simulator', + simulatorId: 'Simulator', + deviceId: 'Device', + arch: 'Architecture', + derivedDataPath: 'Derived Data', + xcresultPath: 'xcresult', + file: 'File', + targetFilter: 'Target Filter', + }; + + const pathKeys = new Set(['workspacePath', 'projectPath', 'derivedDataPath', 'xcresultPath']); + + for (const [key, label] of Object.entries(keyLabelMap)) { + const value = params[key]; + if (typeof value === 'string' && value.length > 0) { + if (key === 'projectPath' && typeof params.workspacePath === 'string') { + continue; + } + if (key === 'simulatorId' && typeof params.simulatorName === 'string') { + continue; + } + let displayValue: string; + if (pathKeys.has(key)) { + displayValue = displayPath(value); + } else if (key === 'deviceId') { + displayValue = formatDeviceId(value); + } else { + displayValue = value; + } + result.push({ label, value: displayValue }); + } + } + + // Always show Derived Data even if not explicitly provided + if (!result.some((r) => r.label === 'Derived Data')) { + result.push({ label: 'Derived Data', value: displayPath(resolveEffectiveDerivedDataPath()) }); + } + + return result; +} + +/** + * Creates a pipeline, emits the initial header event, and captures the start + * timestamp. This consolidates the repeated create-then-emit-start pattern used + * across all build and test tool implementations. + */ +export function startBuildPipeline( + options: PipelineOptions & { message: string }, +): StartedPipeline { + const emit = + options.emit ?? + (() => { + try { + return getHandlerContext().emit; + } catch { + return handlerContextStorage.getStore()?.emit; + } + })(); + const pipeline = createXcodebuildPipeline({ ...options, emit }); + + pipeline.emitEvent({ + type: 'header', + timestamp: new Date().toISOString(), + operation: options.message + .replace(/^[^\p{L}]+/u, '') + .split('\n')[0] + .trim(), + params: buildHeaderParams(options.params), + }); + + return { pipeline, startedAt: Date.now() }; +} + +export function createXcodebuildPipeline(options: PipelineOptions): XcodebuildPipeline { + if (!options.emit) { + throw new Error( + 'Pipeline requires an emit callback. Use startBuildPipeline() or pass emit explicitly.', + ); + } + const logCapture = createLogCapture(options.toolName); + const debugCapture = createParserDebugCapture(options.toolName); + const emit = options.emit; + + const runState = createXcodebuildRunState({ + operation: options.operation, + minimumStage: options.minimumStage, + onEvent: emit, + }); + + const parser = createXcodebuildEventParser({ + operation: options.operation, + onEvent: (event: PipelineEvent) => { + runState.push(event); + }, + onUnrecognizedLine: (line: string) => { + debugCapture.addUnrecognizedLine(line); + }, + }); + + return { + onStdout(chunk: string): void { + logCapture.write(chunk); + parser.onStdout(chunk); + }, + + onStderr(chunk: string): void { + logCapture.write(chunk); + parser.onStderr(chunk); + }, + + emitEvent(event: PipelineEvent): void { + runState.push(event); + }, + + finalize( + succeeded: boolean, + durationMs?: number, + finalizeOptions?: PipelineFinalizeOptions, + ): PipelineResult { + parser.flush(); + logCapture.close(); + + const tailEvents = + finalizeOptions?.includeBuildLogFileRef === false + ? [...(finalizeOptions?.tailEvents ?? [])] + : injectBuildLogIntoTailEvents(finalizeOptions?.tailEvents ?? [], logCapture.path); + + const debugPath = debugCapture.flush(); + if (debugPath) { + appLog( + 'info', + `[Pipeline] ${debugCapture.count} unrecognized parser lines written to ${debugPath}`, + ); + if (finalizeOptions?.includeParserDebugFileRef !== false) { + runState.push({ + type: 'status-line', + timestamp: new Date().toISOString(), + level: 'warning', + message: 'Parsing issue detected - debug log:', + }); + runState.push({ + type: 'file-ref', + timestamp: new Date().toISOString(), + label: 'Parser Debug Log', + path: debugPath, + }); + } + } + + const finalState = runState.finalize(succeeded, durationMs, { + emitSummary: finalizeOptions?.emitSummary, + tailEvents, + }); + + return { + state: finalState, + events: finalState.events, + }; + }, + + highestStageRank(): number { + return runState.highestStageRank(); + }, + + get xcresultPath(): string | null { + return parser.xcresultPath; + }, + + get logPath(): string { + return logCapture.path; + }, + }; +} diff --git a/src/utils/xcresult-test-failures.ts b/src/utils/xcresult-test-failures.ts new file mode 100644 index 00000000..d2c90baa --- /dev/null +++ b/src/utils/xcresult-test-failures.ts @@ -0,0 +1,90 @@ +import { execFileSync } from 'node:child_process'; +import { log } from './logger.ts'; +import type { PipelineEvent } from '../types/pipeline-events.ts'; +import { parseRawTestName } from './xcodebuild-line-parsers.ts'; + +interface XcresultTestNode { + name: string; + nodeType: string; + result?: string; + children?: XcresultTestNode[]; +} + +interface XcresultTestResults { + testNodes: XcresultTestNode[]; +} + +/** + * Extract test failure events from an xcresult bundle using xcresulttool. + * Returns test-failure PipelineEvents for any failed test cases found. + */ +export function extractTestFailuresFromXcresult(xcresultPath: string): PipelineEvent[] { + try { + const output = execFileSync( + 'xcrun', + ['xcresulttool', 'get', 'test-results', 'tests', '--path', xcresultPath], + { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + const results: XcresultTestResults = JSON.parse(output); + const events: PipelineEvent[] = []; + + function walk(node: XcresultTestNode, suiteContext?: string): void { + const parsedNodeName = parseRawTestName(node.name); + const nextSuiteContext = + node.nodeType === 'Test Case' + ? suiteContext + : (parsedNodeName.suiteName ?? + (node.nodeType === 'Test Suite' ? node.name.replaceAll('_', ' ') : suiteContext)); + + if (node.nodeType === 'Test Case' && node.result === 'Failed' && node.children) { + for (const child of node.children) { + if (child.nodeType === 'Failure Message') { + const parsed = parseFailureMessage(child.name); + const { suiteName, testName } = parsedNodeName; + events.push({ + type: 'test-failure', + timestamp: new Date().toISOString(), + operation: 'TEST', + suite: suiteName ?? suiteContext, + test: testName, + message: parsed.message, + location: parsed.location, + }); + } + } + } + if (node.children) { + for (const child of node.children) { + walk(child, nextSuiteContext); + } + } + } + + for (const root of results.testNodes) { + walk(root); + } + + return events; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('debug', `Failed to extract test failures from xcresult: ${message}`); + return []; + } +} + +/** + * Parse a failure message string from xcresulttool. + * Format: "File.swift:11: Expectation failed: 1 == 2: User message" + * or just: "Some failure message" + */ +function parseFailureMessage(raw: string): { message: string; location?: string } { + const match = raw.match(/^(.+?):(\d+): (.+)$/); + if (match) { + return { + location: match[2] === '0' ? undefined : `${match[1]}:${match[2]}`, + message: match[3].replace(/^failed\s*-\s*/u, ''), + }; + } + return { message: raw }; +}